ai-install-guard 0.1.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 +10 -2
- package/CHANGELOG.md +50 -0
- package/README.md +103 -24
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- 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/badge.ts +35 -0
- package/hooks/guard/decisions.ts +44 -3
- package/hooks/guard/pane.tsx +200 -29
- package/hooks/guard/passes.ts +34 -0
- package/hooks/guard/run.ts +158 -0
- package/hooks/guard/view.ts +24 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +117 -75
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +120 -27
- package/hooks/scan/checks/patterns.ts +4 -3
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +5 -2
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/structure.ts +92 -0
- package/hooks/scan/checks/types.ts +18 -2
- package/hooks/scan/checks/validator.ts +15 -1
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +427 -0
- package/hooks/scan/report.ts +12 -3
- package/hooks/scan/scan.ts +94 -8
- package/hooks/scan/verdict.ts +9 -3
- package/package.json +8 -3
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Where an install would come from: the repository, folder and commit Claude
|
|
2
|
+
// Code would install, read from its own plugin listing and marketplace
|
|
3
|
+
// catalogs. Only the `claude` command and file reads are used; nothing from
|
|
4
|
+
// the candidate runs.
|
|
5
|
+
|
|
6
|
+
import type { Host } from '../scan/host'
|
|
7
|
+
import { looksRemote, parseRemote, type RemoteSpec } from '../scan/remote'
|
|
8
|
+
import { safeLine } from '../scan/safe-text'
|
|
9
|
+
import type { InstallRequest } from './requests'
|
|
10
|
+
|
|
11
|
+
export type Target =
|
|
12
|
+
/** One commit of a repository, and the folder in it. */
|
|
13
|
+
| { kind: 'remote'; spec: RemoteSpec }
|
|
14
|
+
/** A folder on this machine: a marketplace's own copy of a plugin, or a marketplace added from a folder. */
|
|
15
|
+
| { kind: 'local'; path: string }
|
|
16
|
+
/** A marketplace name: Claude Code shows its menu to pick a plugin from. */
|
|
17
|
+
| { kind: 'browse' }
|
|
18
|
+
| { kind: 'unverifiable'; reason: string }
|
|
19
|
+
|
|
20
|
+
/** The most a marketplace catalog may weigh before the guard stops reading it. */
|
|
21
|
+
const MAX_CATALOG_BYTES = 4 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
type Listed = { pluginId?: unknown; name?: unknown; marketplaceName?: unknown; source?: unknown }
|
|
24
|
+
type Marketplace = { name: string; installLocation?: string }
|
|
25
|
+
|
|
26
|
+
/** The target an install request names. A lookup that fails says why, as an unverifiable target. */
|
|
27
|
+
export async function targetOf(host: Host, request: InstallRequest): Promise<Target> {
|
|
28
|
+
try {
|
|
29
|
+
return request.kind === 'marketplace' ? await marketplaceTarget(host, request.source) : await pluginTarget(host, request)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { kind: 'unverifiable', reason: error instanceof Error ? error.message : String(error) }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function pluginTarget(host: Host, request: Extract<InstallRequest, { kind: 'plugin' }>): Promise<Target> {
|
|
36
|
+
const listing = await claudeJson(host, ['plugin', 'list', '--available', '--json'], 'the plugin list')
|
|
37
|
+
const available: Listed[] = isRecord(listing) && Array.isArray(listing.available) ? (listing.available as Listed[]) : []
|
|
38
|
+
const installed = isRecord(listing) && Array.isArray(listing.installed) ? (listing.installed as { id?: unknown }[]) : []
|
|
39
|
+
const id = request.marketplace === undefined ? request.plugin : `${request.plugin}@${request.marketplace}`
|
|
40
|
+
const matches = available.filter(item => item.name === request.plugin && (request.marketplace === undefined || item.marketplaceName === request.marketplace))
|
|
41
|
+
// Where marketplaces live is read only when it is needed: for a relative source or a catalog file.
|
|
42
|
+
let listed: Promise<Marketplace[]> | undefined
|
|
43
|
+
const marketplaces = (): Promise<Marketplace[]> => (listed ??= marketplaceList(host))
|
|
44
|
+
|
|
45
|
+
if (matches.length === 0) {
|
|
46
|
+
// An installed plugin is not in the available list: its catalog says where an update comes from.
|
|
47
|
+
// A marketplace named is that marketplace's plugin and no other's.
|
|
48
|
+
const mine = installed.map(item => (typeof item.id === 'string' ? item.id : '')).filter(item => (request.marketplace === undefined ? item.startsWith(`${request.plugin}@`) : item === id))
|
|
49
|
+
if (mine.length > 1 && request.marketplace === undefined) return ambiguous(request.plugin, mine.map(item => item.split('@')[1] ?? ''))
|
|
50
|
+
const owner = mine[0]?.split('@')[1] ?? request.marketplace
|
|
51
|
+
const market = (await marketplaces()).find(item => item.name === owner)
|
|
52
|
+
if (market !== undefined) {
|
|
53
|
+
const entry = await catalogEntry(host, market, request.plugin)
|
|
54
|
+
if (entry !== undefined) return sourceTarget(host, entry, async () => market)
|
|
55
|
+
}
|
|
56
|
+
// `/plugin install <marketplace>` opens that marketplace's menu.
|
|
57
|
+
if (request.verb === 'install' && request.marketplace === undefined && (await marketplaces()).some(item => item.name === request.plugin)) return { kind: 'browse' }
|
|
58
|
+
return { kind: 'unverifiable', reason: `${safeLine(id)} is not in the marketplaces you added, so there was nothing to scan` }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The same plugin can be listed by two marketplaces. One source between them is one target.
|
|
62
|
+
const distinct = new Map(matches.map(item => [sourceKey(item), item]))
|
|
63
|
+
if (distinct.size > 1) return ambiguous(request.plugin, matches.map(item => String(item.marketplaceName)))
|
|
64
|
+
const entry = [...distinct.values()][0] as Listed
|
|
65
|
+
return sourceTarget(host, entry, () => marketplaces().then(list => list.find(item => item.name === entry.marketplaceName)))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ambiguous(plugin: string, marketplaces: readonly string[]): Target {
|
|
69
|
+
const names = [...new Set(marketplaces)].map(safeLine)
|
|
70
|
+
return {
|
|
71
|
+
kind: 'unverifiable',
|
|
72
|
+
reason: `${safeLine(plugin)} is in more than one marketplace (${names.join(', ')}); name one, as in ${safeLine(plugin)}@${names[0] ?? '<marketplace>'}`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A source's identity: a relative source belongs to its marketplace, so two
|
|
78
|
+
* marketplaces' `./x` are never the same; an object source is its fields in
|
|
79
|
+
* name order, however a listing orders them.
|
|
80
|
+
*/
|
|
81
|
+
function sourceKey(item: Listed): string {
|
|
82
|
+
if (typeof item.source === 'string') return `${String(item.marketplaceName)}:${item.source}`
|
|
83
|
+
return JSON.stringify(canonical(item.source ?? null))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function canonical(value: unknown): unknown {
|
|
87
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
88
|
+
if (!isRecord(value)) return value
|
|
89
|
+
return Object.keys(value)
|
|
90
|
+
.sort()
|
|
91
|
+
.map(key => [key, canonical(value[key])])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Where a catalog entry's `source` says the plugin comes from; `marketOf` finds its marketplace when a relative source needs it. */
|
|
95
|
+
async function sourceTarget(host: Host, entry: Listed, marketOf: () => Promise<Marketplace | undefined>): Promise<Target> {
|
|
96
|
+
const source = entry.source
|
|
97
|
+
if (typeof source === 'string') {
|
|
98
|
+
if (!source.startsWith('./')) return { kind: 'unverifiable', reason: `its source "${safeLine(source)}" is not a folder inside its marketplace` }
|
|
99
|
+
const market = await marketOf()
|
|
100
|
+
if (market?.installLocation === undefined) return { kind: 'unverifiable', reason: 'its marketplace has no copy on this machine to read it from' }
|
|
101
|
+
return { kind: 'local', path: await insideFolder(host, market.installLocation, source) }
|
|
102
|
+
}
|
|
103
|
+
if (!isRecord(source) || typeof source.source !== 'string') return { kind: 'unverifiable', reason: 'its catalog entry names no source' }
|
|
104
|
+
const ref = text(source.sha) ?? text(source.ref)
|
|
105
|
+
switch (source.source) {
|
|
106
|
+
case 'url':
|
|
107
|
+
return remote(text(source.url), ref)
|
|
108
|
+
case 'git-subdir':
|
|
109
|
+
// Without its folder the entry would name the whole repository, which is not what installs.
|
|
110
|
+
if (text(source.path) === undefined) return { kind: 'unverifiable', reason: 'its catalog entry names no folder inside its repository' }
|
|
111
|
+
return remote(text(source.url), ref, text(source.path))
|
|
112
|
+
case 'github':
|
|
113
|
+
return remote(text(source.repo) === undefined ? undefined : `https://github.com/${text(source.repo)}.git`, ref)
|
|
114
|
+
default:
|
|
115
|
+
return { kind: 'unverifiable', reason: `it installs from a ${safeLine(source.source)} source, which the guard cannot read yet` }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function remote(url: string | undefined, ref: string | undefined, subdir?: string): Target {
|
|
120
|
+
if (url === undefined) return { kind: 'unverifiable', reason: 'its catalog entry names no repository' }
|
|
121
|
+
return { kind: 'remote', spec: parseRemote(url, { ref, subdir }) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What `marketplace add <source>` would add: a repository, or a folder. */
|
|
125
|
+
async function marketplaceTarget(host: Host, source: string): Promise<Target> {
|
|
126
|
+
const shape = looksRemote(source)
|
|
127
|
+
if (shape === 'local' || (shape === 'maybe' && (await host.stat(source).catch(() => undefined)) !== undefined)) {
|
|
128
|
+
const path = await expandHome(host, source)
|
|
129
|
+
const stat = await host.stat(path).catch(() => undefined)
|
|
130
|
+
if (stat === undefined) return { kind: 'unverifiable', reason: `there is no folder at ${safeLine(source)}` }
|
|
131
|
+
if (stat.kind !== 'dir') return { kind: 'unverifiable', reason: `${safeLine(source)} is a file; the guard scans the folder or repository a marketplace lives in` }
|
|
132
|
+
return { kind: 'local', path }
|
|
133
|
+
}
|
|
134
|
+
const spec = parseRemote(source)
|
|
135
|
+
if (/\.json$/i.test(spec.url)) return { kind: 'unverifiable', reason: 'it adds a catalog file from a web address; the guard scans the repository a marketplace lives in' }
|
|
136
|
+
return { kind: 'remote', spec }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function marketplaceList(host: Host): Promise<Marketplace[]> {
|
|
140
|
+
const listed = await claudeJson(host, ['plugin', 'marketplace', 'list', '--json'], 'the marketplace list')
|
|
141
|
+
if (!Array.isArray(listed)) return []
|
|
142
|
+
return listed.flatMap(item => (isRecord(item) && typeof item.name === 'string' ? [{ name: item.name, installLocation: text(item.installLocation) }] : []))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A plugin's entry in a marketplace's own catalog file. */
|
|
146
|
+
async function catalogEntry(host: Host, market: Marketplace, plugin: string): Promise<Listed | undefined> {
|
|
147
|
+
if (market.installLocation === undefined) return undefined
|
|
148
|
+
const path = await insideFolder(host, market.installLocation, './.claude-plugin/marketplace.json')
|
|
149
|
+
const stat = await host.stat(path)
|
|
150
|
+
if (stat.kind !== 'file' || stat.size > MAX_CATALOG_BYTES) throw new Error(`the catalog of ${safeLine(market.name)} is not a file the guard can read`)
|
|
151
|
+
let parsed: unknown
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(new TextDecoder().decode(await host.readBytes(path)))
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error(`the catalog of ${safeLine(market.name)} is not valid JSON`)
|
|
156
|
+
}
|
|
157
|
+
const plugins = isRecord(parsed) && Array.isArray(parsed.plugins) ? parsed.plugins : []
|
|
158
|
+
const found = plugins.find(item => isRecord(item) && item.name === plugin)
|
|
159
|
+
return isRecord(found) ? { name: plugin, marketplaceName: market.name, source: found.source } : undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `relative` inside `folder`: no `..` above it, and no symbolic link on the
|
|
164
|
+
* way, which could lead the scan (and the install) out to other files.
|
|
165
|
+
*/
|
|
166
|
+
async function insideFolder(host: Host, folder: string, relative: string): Promise<string> {
|
|
167
|
+
const parts = relative.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
|
|
168
|
+
if (parts.includes('..')) throw new Error(`"${safeLine(relative)}" leads outside its marketplace`)
|
|
169
|
+
let path = folder.replace(/[\\/]+$/, '')
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
path = `${path}/${part}`
|
|
172
|
+
const stat = await host.stat(path, { resolve: false }).catch(() => undefined)
|
|
173
|
+
if (stat === undefined) throw new Error(`its marketplace has no ${safeLine(relative)}`)
|
|
174
|
+
if (stat.isLink) throw new Error(`"${safeLine(relative)}" passes through a symbolic link, which could lead outside its marketplace`)
|
|
175
|
+
}
|
|
176
|
+
return path
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Runs `claude <args>` and reads its JSON answer. */
|
|
180
|
+
async function claudeJson(host: Host, args: readonly string[], what: string): Promise<unknown> {
|
|
181
|
+
const result = await host.run(['claude', ...args], { timeoutMs: 60_000 }).catch((error: unknown) => {
|
|
182
|
+
throw new Error(`could not read ${what}: ${error instanceof Error ? safeLine(error.message) : 'the claude command did not run'}`)
|
|
183
|
+
})
|
|
184
|
+
if (result.exitCode !== 0) {
|
|
185
|
+
const line = result.stderr.split('\n').find(item => item.trim() !== '') ?? `claude exited with ${result.exitCode}`
|
|
186
|
+
throw new Error(`could not read ${what}: ${safeLine(line.trim().slice(0, 300))}`)
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(result.stdout)
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(`could not read ${what}: its answer was not valid JSON`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `~` and `~/…` as the full path of your home folder. */
|
|
196
|
+
export async function expandHome(host: Host, path: string): Promise<string> {
|
|
197
|
+
if (path !== '~' && !path.startsWith('~/')) return path
|
|
198
|
+
const { exitCode, stdout } = await host.run(['printenv', 'HOME'])
|
|
199
|
+
const home = stdout.trim()
|
|
200
|
+
if (exitCode !== 0 || home === '') throw new Error('could not find the home folder')
|
|
201
|
+
return home + path.slice(1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
205
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function text(value: unknown): string | undefined {
|
|
209
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
210
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { GuardView } from '../../types'
|
|
2
|
+
|
|
3
|
+
/** Where the badge links: what it means and how to check it. */
|
|
4
|
+
export const BADGE_PAGE = 'https://github.com/ziniman/ai-install-guard#review-badge'
|
|
5
|
+
|
|
6
|
+
const COLOR = { safe: '2ea44f', caution: 'dbab09' } as const
|
|
7
|
+
const MESSAGE = { safe: 'safe to install', caution: 'review what it can do' } as const
|
|
8
|
+
|
|
9
|
+
/** A shields.io static badge part: dashes and underscores doubled, spaces as underscores. */
|
|
10
|
+
function shieldPart(text: string): string {
|
|
11
|
+
return encodeURIComponent(text.replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The README badge for the last scan: its verdict and the exact version it
|
|
16
|
+
* covers. Offered for Safe to install and Review what it can do only, and
|
|
17
|
+
* only for a scan that read the files, so it always names what was scanned.
|
|
18
|
+
*/
|
|
19
|
+
export function badgeFor(view: GuardView | undefined): { markdown: string; pin: string } | { refusal: string } {
|
|
20
|
+
if (view?.verdict === undefined || view.treeHash === undefined) return { refusal: 'Scan something first: /guard scan <path | repository>.' }
|
|
21
|
+
const kind = view.verdict.kind
|
|
22
|
+
if (kind !== 'safe' && kind !== 'caution') return { refusal: `There is no badge for ${view.verdict.recommendation}.` }
|
|
23
|
+
// Only hex from the scan goes into the badge, never text from the candidate.
|
|
24
|
+
const sha = view.sha !== undefined && /^[0-9a-f]{40,64}$/.test(view.sha) ? view.sha : undefined
|
|
25
|
+
const tree = /^sha256:[0-9a-f]{64}$/.test(view.treeHash) ? view.treeHash : undefined
|
|
26
|
+
if (tree === undefined) return { refusal: 'The last scan has no tree hash to pin a badge to.' }
|
|
27
|
+
const pin = sha !== undefined ? sha.slice(0, 7) : `tree ${tree.slice(7, 15)}`
|
|
28
|
+
// A scan of one folder always says so: the rest of the commit was not
|
|
29
|
+
// judged. The view holds the folder percent-encoded, whole, never cut.
|
|
30
|
+
const folder = sha !== undefined ? view.subdir : undefined
|
|
31
|
+
if (folder !== undefined && !/^[\w./%-]{1,400}$/.test(folder)) return { refusal: 'The folder name is too long to show in a badge.' }
|
|
32
|
+
const image = `https://img.shields.io/badge/${shieldPart('AI Install Guard')}-${shieldPart(`${MESSAGE[kind]} · ${pin}`)}-${COLOR[kind]}`
|
|
33
|
+
const scanned = sha !== undefined ? `commit ${sha}${folder === undefined ? '' : `, folder ${folder}`}, ${tree}` : tree
|
|
34
|
+
return { markdown: `[](${BADGE_PAGE} "Scanned at ${scanned}")`, pin }
|
|
35
|
+
}
|
package/hooks/guard/decisions.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { safeLine } from '../scan/safe-text'
|
|
|
7
7
|
export type Decision = {
|
|
8
8
|
target: string
|
|
9
9
|
root?: string
|
|
10
|
+
/** The commit, for a remote source. */
|
|
11
|
+
sha?: string
|
|
10
12
|
treeHash: string
|
|
11
13
|
verdict: string
|
|
12
14
|
/** The verdict the decision was made on; an approval holds up to this one. */
|
|
@@ -20,6 +22,8 @@ export type Decision = {
|
|
|
20
22
|
const DECISIONS = 'decisions'
|
|
21
23
|
/** Room for the report text, well inside the store's 4 MiB in all. */
|
|
22
24
|
export const MAX_REPORT_CHARS = 1_000_000
|
|
25
|
+
/** The last report as text, under the name of the scan that wrote it. */
|
|
26
|
+
const REPORT_TEXT = 'last-report-text'
|
|
23
27
|
const LAST_VIEW = 'last-view'
|
|
24
28
|
/** The store is capped at 4 MiB in all: keep the newest decisions only. */
|
|
25
29
|
const MAX_DECISIONS = 500
|
|
@@ -31,8 +35,13 @@ export async function loadDecisions($: EngineInterface): Promise<Decision[]> {
|
|
|
31
35
|
return Array.isArray(stored) ? (stored as Decision[]) : []
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/** One decision per version: the same files at the same commit (or as a folder). */
|
|
39
|
+
function sameVersion(a: { treeHash: string; sha?: string }, b: { treeHash: string; sha?: string }): boolean {
|
|
40
|
+
return a.treeHash === b.treeHash && (a.sha ?? '') === (b.sha ?? '')
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
export async function recordDecision($: EngineInterface, decision: Decision): Promise<void> {
|
|
35
|
-
const others = (await loadDecisions($)).filter(item => item
|
|
44
|
+
const others = (await loadDecisions($)).filter(item => !sameVersion(item, decision))
|
|
36
45
|
await $.store.set(DECISIONS, [decision, ...others].slice(0, MAX_DECISIONS))
|
|
37
46
|
}
|
|
38
47
|
|
|
@@ -65,13 +74,45 @@ export async function forgetDecisions($: EngineInterface, name: string): Promise
|
|
|
65
74
|
* one it was given on: the same files can judge worse in another place (a
|
|
66
75
|
* copy whose absolute link now leaves the folder), and that needs a new look.
|
|
67
76
|
*/
|
|
68
|
-
export async function decisionFor($: EngineInterface, treeHash: string, kind: GuardVerdict['kind']): Promise<Decision | undefined> {
|
|
69
|
-
|
|
77
|
+
export async function decisionFor($: EngineInterface, version: { treeHash: string; sha?: string }, kind: GuardVerdict['kind']): Promise<Decision | undefined> {
|
|
78
|
+
// A decision is about one version: another commit with the same files, or
|
|
79
|
+
// the same files as a local folder, is judged on its own.
|
|
80
|
+
const found = (await loadDecisions($)).find(item => sameVersion(item, version))
|
|
70
81
|
if (found === undefined || found.decision === 'rejected') return found
|
|
71
82
|
const approvedUpTo = SEVERITY[found.verdictKind] as number | undefined
|
|
72
83
|
return approvedUpTo !== undefined && SEVERITY[kind] <= approvedUpTo ? found : undefined
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The decision on one commit of one source, found before any scan: a commit
|
|
88
|
+
* names its files exactly, so the same commit of the same folder is the same
|
|
89
|
+
* version however it is asked for.
|
|
90
|
+
*/
|
|
91
|
+
export async function pinnedDecision($: EngineInterface, pin: { root: string; sha: string }): Promise<Decision | undefined> {
|
|
92
|
+
return (await loadDecisions($)).find(item => item.sha === pin.sha && item.root === pin.root)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stores the text report of one scan, held well inside the store's limit. A
|
|
97
|
+
* failed write leaves no report, never another scan's.
|
|
98
|
+
*/
|
|
99
|
+
export async function saveReport($: EngineInterface, scanId: string, text: string): Promise<void> {
|
|
100
|
+
// A newer scan's report is never replaced by an older one's.
|
|
101
|
+
const stored = await $.store.get(REPORT_TEXT).catch(() => undefined)
|
|
102
|
+
const storedId = stored !== null && typeof stored === 'object' ? (stored as { scanId?: unknown }).scanId : undefined
|
|
103
|
+
if (typeof storedId === 'string' && storedId > scanId) return
|
|
104
|
+
const kept = text.length > MAX_REPORT_CHARS ? `${text.slice(0, MAX_REPORT_CHARS)}\n… the report was cut here to fit the store` : text
|
|
105
|
+
await $.store.set(REPORT_TEXT, { scanId, text: kept }).catch(() => $.store.delete(REPORT_TEXT).catch(() => undefined))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The text report of the scan `scanId` names, if it is still the one stored. */
|
|
109
|
+
export async function loadReport($: EngineInterface, scanId: string | undefined): Promise<string | undefined> {
|
|
110
|
+
const stored = await $.store.get(REPORT_TEXT)
|
|
111
|
+
if (stored === null || typeof stored !== 'object') return undefined
|
|
112
|
+
const { scanId: storedId, text } = stored as { scanId?: unknown; text?: unknown }
|
|
113
|
+
return storedId === scanId && typeof text === 'string' ? text : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
/**
|
|
76
117
|
* Saves the view /guard report reopens. When it cannot be saved, the old one
|
|
77
118
|
* is removed, so a reopened report never shows an older verdict as current.
|