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
package/hooks/register.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { EngineInterface, Register } from 'claude-code'
|
|
2
2
|
import { atom, update } from 'claude-code'
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { BROWSE_NOTE, judge } from './gate/gate'
|
|
5
|
+
import { readPluginArgs, readShellCommand } from './gate/requests'
|
|
6
|
+
import { expandHome } from './gate/resolve'
|
|
7
|
+
import { badgeFor } from './guard/badge'
|
|
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'
|
|
7
12
|
import { modHost } from './mod-host'
|
|
8
13
|
import type { Host } from './scan/host'
|
|
9
|
-
import {
|
|
14
|
+
import { looksRemote, parseRemote, type RemoteSpec } from './scan/remote'
|
|
10
15
|
import { safeLine } from './scan/safe-text'
|
|
11
|
-
import { scanFolder } from './scan/scan'
|
|
12
16
|
|
|
13
17
|
// Claude Code reads every state reference from the file that uses it, so each
|
|
14
18
|
// module declares its own atom for the pane's view.
|
|
@@ -17,26 +21,36 @@ const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null
|
|
|
17
21
|
const USAGE = [
|
|
18
22
|
'Usage:',
|
|
19
23
|
' /guard scan <path> Scan a local plugin, mod or skill folder without installing it',
|
|
20
|
-
' /guard scan
|
|
24
|
+
' /guard scan <repository> Fetch one commit of a repository and scan it: https://host/owner/repo,',
|
|
25
|
+
' github:owner/repo or owner/repo, with an optional @ref',
|
|
26
|
+
' --ref <branch|tag|commit> The commit to scan (the default branch otherwise)',
|
|
27
|
+
' --path <folder> A folder inside the repository',
|
|
28
|
+
' --details The full report as text',
|
|
21
29
|
' /guard report Reopen the last report',
|
|
30
|
+
' /guard badge A README badge for the last scan (Safe to install or Review what it can do)',
|
|
22
31
|
' /guard list Your approved and rejected versions',
|
|
23
32
|
' /guard forget <name> Forget decisions about a name, so it is judged again',
|
|
24
33
|
].join('\n')
|
|
25
34
|
|
|
26
|
-
export const register: Register = on => {
|
|
35
|
+
export const register: Register = (on, options) => {
|
|
36
|
+
// Off unless you turn it on: the lookups send package and plugin names out.
|
|
37
|
+
const lookups = options.outsideLookups === true
|
|
38
|
+
|
|
27
39
|
on('session.start', async ($, e, next) => {
|
|
28
40
|
await $.command.register({
|
|
29
41
|
name: 'guard',
|
|
30
42
|
description: 'Scan a plugin, mod, skill or MCP server without installing it',
|
|
31
|
-
argumentHint: 'scan <path> | report | list | forget <name>',
|
|
43
|
+
argumentHint: 'scan <path | repository> | report | badge | list | forget <name>',
|
|
32
44
|
})
|
|
33
45
|
return next(e)
|
|
34
46
|
})
|
|
35
47
|
|
|
36
48
|
on('command.run', { command: 'guard' }, async ($, e) => {
|
|
37
|
-
const { sub, rest, details } = splitArgs(e.args)
|
|
38
|
-
if (
|
|
49
|
+
const { sub, rest, details, ref, path, error } = splitArgs(e.args)
|
|
50
|
+
if (error !== undefined) return { text: `AI Install Guard: ${error}\n\n${USAGE}` }
|
|
51
|
+
if (sub === 'scan' && rest !== '') return { text: await scan($, rest, details, { ref, path }, lookups) }
|
|
39
52
|
if (sub === 'report') return { text: await reopen($) }
|
|
53
|
+
if (sub === 'badge') return { text: await badge($) }
|
|
40
54
|
if (sub === 'list') return { text: await list($) }
|
|
41
55
|
if (sub === 'forget' && rest !== '') {
|
|
42
56
|
const count = (await forgetDecisions($, rest)).length
|
|
@@ -45,6 +59,33 @@ export const register: Register = on => {
|
|
|
45
59
|
return { text: USAGE }
|
|
46
60
|
}).catch(() => ({ text: 'AI Install Guard: the command failed before it could answer.' }))
|
|
47
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
|
+
|
|
48
89
|
on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawPane($, e))
|
|
49
90
|
}
|
|
50
91
|
|
|
@@ -54,57 +95,43 @@ export const register: Register = on => {
|
|
|
54
95
|
* findings stay in the pane. Where no pane can show (a headless run), or with
|
|
55
96
|
* --details, the answer is the full text report.
|
|
56
97
|
*/
|
|
57
|
-
async function scan($: EngineInterface, target: string, details: boolean): Promise<string> {
|
|
98
|
+
async function scan($: EngineInterface, target: string, details: boolean, flags: { ref?: string; path?: string }, lookups: boolean): Promise<string> {
|
|
58
99
|
const host = modHost($)
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const earlier = report.inventory === undefined ? undefined : await decisionFor($, report.inventory.treeHash, report.verdict.kind).catch(() => undefined)
|
|
73
|
-
const view = earlier === undefined ? viewOf(report, 'approve') : { ...viewOf(report, 'approve'), decision: earlier.decision, decidedAt: earlier.decidedAt }
|
|
74
|
-
const text = formatReport(report, { details: true })
|
|
75
|
-
// The report text is stored before the pane shows the verdict, so Copy
|
|
76
|
-
// report always copies this scan's report. It is held well inside the
|
|
77
|
-
// store's limit; a failed write leaves Copy with nothing, not old text.
|
|
78
|
-
const stored = text.length > MAX_REPORT_CHARS ? `${text.slice(0, MAX_REPORT_CHARS)}\n… the report was cut here to fit the store` : text
|
|
79
|
-
await $.store.set(REPORT_TEXT, stored).catch(() => undefined)
|
|
80
|
-
await update($, viewAtom, () => view)
|
|
81
|
-
await saveLastView($, view).catch(() => undefined)
|
|
82
|
-
$.ui.toast(`AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}`)
|
|
83
|
-
// Where no pane shows, the answer is the whole report: findings and advice.
|
|
84
|
-
if (!opened.isPlaced || details) return text
|
|
85
|
-
return `AI Install Guard: ${report.verdict.recommendation} for ${safeLine(target)}. The report is in the pane; /guard report reopens it.`
|
|
86
|
-
} catch (error) {
|
|
87
|
-
const reason = `Could not verify ${safeLine(target)}: ${safeLine(reasonOf(error))}`
|
|
88
|
-
await $.store.set(REPORT_TEXT, reason).catch(() => undefined)
|
|
89
|
-
// The pane, and the report /guard report reopens, show this failure: never
|
|
90
|
-
// a scanning pane, and never an older verdict in its place.
|
|
91
|
-
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 } } }
|
|
92
|
-
await update($, viewAtom, () => failed).catch(() => undefined)
|
|
93
|
-
await saveLastView($, failed).catch(() => undefined)
|
|
94
|
-
return reason
|
|
95
|
-
} finally {
|
|
96
|
-
$.ui.status(undefined)
|
|
97
|
-
}
|
|
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.`
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
async function reopen($: EngineInterface): Promise<string> {
|
|
101
116
|
const view = await loadLastView($)
|
|
102
117
|
if (view === undefined) return 'AI Install Guard: no report yet. Run /guard scan <path>.'
|
|
103
|
-
await update($, viewAtom, () => ({ ...view, confirming: false }))
|
|
118
|
+
await update($, viewAtom, () => ({ ...view, confirming: false, deciding: false }))
|
|
104
119
|
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
105
120
|
if (opened.isPlaced) return `AI Install Guard: reopened the report for ${safeLine(view.target)}.`
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
return (await loadReport($, view.scanId)) ?? 'AI Install Guard: the last report is no longer stored.'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function badge($: EngineInterface): Promise<string> {
|
|
125
|
+
const view = await loadLastView($)
|
|
126
|
+
const made = badgeFor(view)
|
|
127
|
+
if ('refusal' in made) return `AI Install Guard: ${made.refusal}`
|
|
128
|
+
return [
|
|
129
|
+
`AI Install Guard: a README badge for ${view?.target ?? 'the last scan'} at ${made.pin}. Paste it near the top of your README:`,
|
|
130
|
+
'',
|
|
131
|
+
made.markdown,
|
|
132
|
+
'',
|
|
133
|
+
'It names the exact version that was scanned. A change to your code needs a new scan and a new badge; the guard checks a badge against its own scan and never trusts it.',
|
|
134
|
+
].join('\n')
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
async function list($: EngineInterface): Promise<string> {
|
|
@@ -119,36 +146,51 @@ async function list($: EngineInterface): Promise<string> {
|
|
|
119
146
|
}
|
|
120
147
|
|
|
121
148
|
/**
|
|
122
|
-
* `scan --details "my folder"` → `{ sub: 'scan', details: true, rest: 'my folder' }`.
|
|
123
|
-
*
|
|
124
|
-
* quoted path that contains
|
|
149
|
+
* `scan --details --ref v1 "my folder"` → `{ sub: 'scan', details: true, ref: 'v1', rest: 'my folder' }`.
|
|
150
|
+
* Options count only as their own tokens right after the subcommand, so a
|
|
151
|
+
* quoted path that contains one is left whole.
|
|
125
152
|
*/
|
|
126
|
-
export function splitArgs(args: string): { sub: string; rest: string; details: boolean } {
|
|
153
|
+
export function splitArgs(args: string): { sub: string; rest: string; details: boolean; ref?: string; path?: string; error?: string } {
|
|
127
154
|
const trimmed = args.trim()
|
|
128
155
|
const space = trimmed.search(/\s/)
|
|
129
156
|
const sub = space === -1 ? trimmed : trimmed.slice(0, space)
|
|
130
157
|
let rest = space === -1 ? '' : trimmed.slice(space).trim()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
158
|
+
let details = false
|
|
159
|
+
let ref: string | undefined
|
|
160
|
+
let path: string | undefined
|
|
161
|
+
for (;;) {
|
|
162
|
+
const option = /^(--details|--ref|--path)(?:\s+|$)/.exec(rest)
|
|
163
|
+
if (option === null) break
|
|
164
|
+
rest = rest.slice(option[0].length)
|
|
165
|
+
if (option[1] === '--details') {
|
|
166
|
+
details = true
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
const value = /^("[^"]*"|'[^']*'|\S+)\s*/.exec(rest)
|
|
170
|
+
if (value === null || (value[1] as string).startsWith('--')) return { sub: sub.toLowerCase(), rest: '', details, error: `${option[1]} needs a value` }
|
|
171
|
+
rest = rest.slice(value[0].length)
|
|
172
|
+
if (option[1] === '--ref') ref = unquote(value[1] as string)
|
|
173
|
+
else path = unquote(value[1] as string)
|
|
174
|
+
}
|
|
175
|
+
return { sub: sub.toLowerCase(), rest: unquote(rest), details, ref, path }
|
|
138
176
|
}
|
|
139
177
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The remote source `target` names, or undefined for a local folder. A bare
|
|
180
|
+
* owner/repo is a folder when one exists by that name and no --ref or --path
|
|
181
|
+
* says otherwise.
|
|
182
|
+
*/
|
|
183
|
+
async function remoteSpecFor(host: Host, target: string, flags: { ref?: string; path?: string }): Promise<RemoteSpec | undefined> {
|
|
184
|
+
const shape = looksRemote(target)
|
|
185
|
+
const asked = flags.ref !== undefined || flags.path !== undefined
|
|
186
|
+
if (shape === 'local') {
|
|
187
|
+
if (asked) throw new Error('--ref and --path apply to a repository, not to a folder')
|
|
188
|
+
return undefined
|
|
148
189
|
}
|
|
149
|
-
|
|
190
|
+
if (shape === 'maybe' && !asked && (await host.stat(target).catch(() => undefined)) !== undefined) return undefined
|
|
191
|
+
return parseRemote(target, { ref: flags.ref, subdir: flags.path })
|
|
150
192
|
}
|
|
151
193
|
|
|
152
|
-
function
|
|
153
|
-
return
|
|
194
|
+
function unquote(text: string): string {
|
|
195
|
+
return /^(["']).*\1$/.test(text) ? text.slice(1, -1) : text
|
|
154
196
|
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
WRITE_SHELL,
|
|
22
22
|
} from './patterns'
|
|
23
23
|
import { basename, extension, findAll, findFirst, spanKinds, type Match, type SpanKind } from './text'
|
|
24
|
+
import { describePrograms, programsIn, type ProgramUse } from './programs'
|
|
24
25
|
import { row, worse, type CheckResult, type Evidence, type Status } from './types'
|
|
25
26
|
|
|
26
27
|
const GROUP = 'code' as const
|
|
@@ -159,6 +160,7 @@ const ANSI = /\u001b\[[0-9;?]{0,40}[A-Za-z]|\u001b\][^\u0007\u001b]{0,1000}\u000
|
|
|
159
160
|
|
|
160
161
|
function hiddenCheck(ctx: ScanContext): CheckResult {
|
|
161
162
|
const evidence: Evidence[] = []
|
|
163
|
+
let foundLone = false
|
|
162
164
|
for (const file of ctx.files) {
|
|
163
165
|
const text = file.isText ? ctx.text(file.path) : undefined
|
|
164
166
|
if (text === undefined) continue
|
|
@@ -172,10 +174,24 @@ function hiddenCheck(ctx: ScanContext): CheckResult {
|
|
|
172
174
|
for (const match of findAll(text, VARIATION_SUPPLEMENT, 3)) {
|
|
173
175
|
evidence.push({ file: file.path, line: match.line, excerpt: `${[...match.text].length} invisible variation selectors in a row (a way to smuggle data)`, status: cap('fail') })
|
|
174
176
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
// In code a control that is all of a string, with nothing after it, is
|
|
178
|
+
// only a warning, and only in a language the tokenizer knows. Failures
|
|
179
|
+
// are found first, so no number of such strings can crowd out a real one.
|
|
180
|
+
const language = isCode ? (ctx.interpreters.get(file.path) ?? languageOf(file.path, text)) : 'other'
|
|
181
|
+
let kinds: ((index: number) => SpanKind) | undefined
|
|
182
|
+
const kindAt = (index: number): SpanKind => (kinds ??= spanKinds(text, language))(index)
|
|
183
|
+
const lone = (index: number): boolean => language !== 'other' && isLoneInString(text, index, kindAt)
|
|
184
|
+
for (const match of findAll(text, BIDI, 5, index => !lone(index))) {
|
|
177
185
|
evidence.push({ file: file.path, line: match.line, excerpt: `bidirectional control ${codePoints(match.text)}: text may read differently than it runs`, status: cap(isCode ? 'fail' : 'warn') })
|
|
178
186
|
}
|
|
187
|
+
if (language !== 'other') {
|
|
188
|
+
for (const match of findAll(text, BIDI, 3, lone)) {
|
|
189
|
+
const status = cap('warn')
|
|
190
|
+
if (status === 'warn') foundLone = true
|
|
191
|
+
const excerpt = `bidirectional control ${codePoints(match.text)} is all of a string: it cannot reorder code there, but the string looks empty; write it as ${escapeFor(match, file.path, text, language)}`
|
|
192
|
+
evidence.push({ file: file.path, line: match.line, excerpt, status })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
179
195
|
const zero = findAll(text, ZERO_WIDTH, 50)
|
|
180
196
|
if (zero.length > 0 && (isCode || isMarkdown(file.path))) {
|
|
181
197
|
const first = zero[0] as (typeof zero)[number]
|
|
@@ -191,12 +207,52 @@ function hiddenCheck(ctx: ScanContext): CheckResult {
|
|
|
191
207
|
id: 'code.hidden-characters',
|
|
192
208
|
group: GROUP,
|
|
193
209
|
title: 'Hidden characters',
|
|
194
|
-
advice:
|
|
210
|
+
advice: `Invisible tag characters carry text the model reads and you do not see. Bidirectional controls make code read differently than it runs. Neither belongs in a plugin.${
|
|
211
|
+
foundLone ? " A plugin that needs a direction mark, as one for right-to-left text does, should write it as an escape such as '\\u200f', never the raw character, which makes its string look empty." : ''
|
|
212
|
+
}`,
|
|
195
213
|
},
|
|
196
214
|
evidence,
|
|
197
215
|
)
|
|
198
216
|
}
|
|
199
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Whether the bidirectional control at `index` is all of a one-line string,
|
|
220
|
+
* as in `const RLM = '\u200f'`, with nothing after it on its line but blanks,
|
|
221
|
+
* `;`, `,` or a comment. What it can reorder runs to the end of its line, and
|
|
222
|
+
* there that is no code; what comes before it does not move.
|
|
223
|
+
*/
|
|
224
|
+
function isLoneInString(text: string, index: number, kindAt: (index: number) => SpanKind): boolean {
|
|
225
|
+
const quote = text[index - 1]
|
|
226
|
+
if ((quote !== "'" && quote !== '"') || text[index + 1] !== quote) return false
|
|
227
|
+
if (kindAt(index - 1) !== 'string' || kindAt(index) !== 'string' || kindAt(index + 1) !== 'string') return false
|
|
228
|
+
// The quote must open the string: what comes before it is no string, and no
|
|
229
|
+
// `}`, which may close a template's `${...}` and leave the quote as its text.
|
|
230
|
+
if (index >= 2 && (kindAt(index - 2) === 'string' || text[index - 2] === '}')) return false
|
|
231
|
+
// Only LF ends the line here. U+2028 and U+2029 end a JavaScript line, but
|
|
232
|
+
// editors often show them inline and U+2028 ends no bidi paragraph, so code
|
|
233
|
+
// after one can still be reordered: that fails.
|
|
234
|
+
for (let i = index + 2; i < text.length && text[i] !== '\n'; i += 1) {
|
|
235
|
+
const char = text[i]
|
|
236
|
+
if (char === ' ' || char === '\t' || char === ';' || char === ',') continue
|
|
237
|
+
// Only a CR that ends the line: a lone one ends a line too, and code may follow it.
|
|
238
|
+
if (char === '\r' && (i + 1 === text.length || text[i + 1] === '\n')) continue
|
|
239
|
+
const kind = kindAt(i)
|
|
240
|
+
if (kind !== 'comment' && kind !== 'trailing-comment') return false
|
|
241
|
+
}
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `'\u200f'` for a lone control, as its language writes the escape. */
|
|
246
|
+
function escapeFor(match: Match, path: string, text: string, language: Language): string {
|
|
247
|
+
const hex = (match.text.codePointAt(0) as number).toString(16).padStart(4, '0')
|
|
248
|
+
if (language !== 'shell') return `${text[match.index - 1]}\\u${hex}${text[match.index - 1]}`
|
|
249
|
+
// fish reads the escape bare. Other shells differ (sh has no such escape,
|
|
250
|
+
// and bash before 4.2 not in $'...'), but every printf takes octal bytes.
|
|
251
|
+
if ((interpreterOf(text) || extension(path)) === 'fish') return `\\u${hex}`
|
|
252
|
+
const octal = [...new TextEncoder().encode(match.text)].map(byte => `\\${byte.toString(8)}`).join('')
|
|
253
|
+
return `"$(printf '${octal}')"`
|
|
254
|
+
}
|
|
255
|
+
|
|
200
256
|
/** Tag characters U+E0020..U+E007E mirror printable ASCII. */
|
|
201
257
|
export function decodeTags(text: string): string {
|
|
202
258
|
let out = ''
|
|
@@ -213,6 +269,9 @@ function codePoints(text: string): string {
|
|
|
213
269
|
|
|
214
270
|
function execCheck(ctx: ScanContext): CheckResult {
|
|
215
271
|
const evidence: Evidence[] = []
|
|
272
|
+
const uses: ProgramUse[] = []
|
|
273
|
+
let buildsCode = 0
|
|
274
|
+
let downloads = 0
|
|
216
275
|
for (const file of codeFiles(ctx)) {
|
|
217
276
|
// Code files, and anything live code runs or loads whatever its name.
|
|
218
277
|
if (!isCodeFile(file.path, file.text) && !LIVE_ROLES.has(file.role)) continue
|
|
@@ -227,18 +286,35 @@ function execCheck(ctx: ScanContext): CheckResult {
|
|
|
227
286
|
evidence.push(...evidenceFor(file, pattern, inModule ? 'fail' : 'warn', match => `${what}: ${match.excerpt}`, 3))
|
|
228
287
|
}
|
|
229
288
|
evidence.push(...evidenceFor(file, DOWNLOAD_AND_RUN, 'fail', match => `downloads or decodes code and runs it: ${match.excerpt}`, 3))
|
|
289
|
+
// The summary counts live code only: tests and docs are only reported.
|
|
290
|
+
if (isLive(file.role)) {
|
|
291
|
+
const keep = (index: number) => !['comment', 'regex', 'trailing-comment'].includes(file.kindAt(index))
|
|
292
|
+
uses.push(...programsIn(file.path, file.text, file.language, keep))
|
|
293
|
+
// The same language rules as the evidence, each place counted once.
|
|
294
|
+
const fromText = file.language === 'js' ? [CODE_FROM_TEXT] : file.language === 'python' ? [PYTHON_CODE_FROM_TEXT] : file.language === 'shell' ? [SHELL_EVAL] : [CODE_FROM_TEXT, PYTHON_CODE_FROM_TEXT, SHELL_EVAL]
|
|
295
|
+
buildsCode += new Set(fromText.flatMap(pattern => findAll(file.text, pattern, 50, keep).map(match => match.line))).size
|
|
296
|
+
downloads += findAll(file.text, DOWNLOAD_AND_RUN, 50, keep).length
|
|
297
|
+
}
|
|
230
298
|
}
|
|
231
299
|
return row(
|
|
232
300
|
{
|
|
233
301
|
id: 'code.execution',
|
|
234
302
|
group: GROUP,
|
|
235
303
|
title: 'Running programs and building code at run time',
|
|
236
|
-
advice:
|
|
304
|
+
advice:
|
|
305
|
+
'Check that each program fits what it says it does. Shells, compilers and programs chosen while it runs can do anything, so look at those first. Inside a mod any of this is a red flag: a mod reaches your machine only through Claude Code.',
|
|
306
|
+
summary: summaryOrFallback(describePrograms(uses, { buildsCode, downloads }), evidence),
|
|
237
307
|
},
|
|
238
308
|
evidence,
|
|
239
309
|
)
|
|
240
310
|
}
|
|
241
311
|
|
|
312
|
+
/** A row that found something but could not name it still says so in words. */
|
|
313
|
+
function summaryOrFallback(summary: string[], evidence: readonly Evidence[]): string[] {
|
|
314
|
+
if (summary.length > 0 || !evidence.some(item => item.status === 'warn' || item.status === 'fail')) return summary
|
|
315
|
+
return ['Starts other programs; which ones could not be read from the code']
|
|
316
|
+
}
|
|
317
|
+
|
|
242
318
|
function obfuscationCheck(ctx: ScanContext): CheckResult {
|
|
243
319
|
const evidence: Evidence[] = []
|
|
244
320
|
for (const file of codeFiles(ctx)) {
|
|
@@ -402,12 +478,36 @@ function denseLine(text: string): Match | undefined {
|
|
|
402
478
|
|
|
403
479
|
function credentialsCheck(ctx: ScanContext): CheckResult {
|
|
404
480
|
const evidence: Evidence[] = []
|
|
481
|
+
const secrets = new Set<string>()
|
|
482
|
+
const places = new Set<string>()
|
|
483
|
+
const files = new Set<string>()
|
|
484
|
+
const sending = new Set<string>()
|
|
405
485
|
for (const file of codeFiles(ctx)) {
|
|
406
|
-
|
|
486
|
+
// Hook scripts and MCP configuration are judged where their target is
|
|
487
|
+
// known: the hooks check, and mcp.credentials with the server's host.
|
|
488
|
+
if (file.role === 'hook-script' || file.role === 'mcp-config') continue
|
|
407
489
|
const sends = [NETWORK_CODE, NETWORK_COMMAND, COLLECTION_HOST].some(pattern => active(file, pattern, 1).length > 0)
|
|
408
490
|
const lead = sends ? 'with network code in the same file: ' : ''
|
|
409
|
-
|
|
410
|
-
|
|
491
|
+
const paths = evidenceFor(file, CREDENTIAL_PATH, sends ? 'fail' : 'warn', match => `${lead}${match.excerpt}`, 3)
|
|
492
|
+
const names = evidenceFor(file, SECRET_ENV, sends ? 'fail' : 'warn', match => `${lead}reads ${secretName(match)}`, 3)
|
|
493
|
+
evidence.push(...paths, ...names)
|
|
494
|
+
if (!isLive(file.role)) continue
|
|
495
|
+
for (const match of active(file, CREDENTIAL_PATH, 20)) places.add(match.text.trim().replace(/\/$/, ''))
|
|
496
|
+
// A dump of the whole environment (JSON.stringify(process.env), printenv) is every secret at once.
|
|
497
|
+
for (const match of active(file, SECRET_ENV, 20)) secrets.add(/process\.env|os\.environ|printenv|\benv\b/.test(match.text) ? 'all your environment variables' : secretName(match))
|
|
498
|
+
if (paths.length + names.length > 0) {
|
|
499
|
+
files.add(file.path)
|
|
500
|
+
if (sends) sending.add(file.path)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const summary: string[] = []
|
|
504
|
+
const things = [...secrets, ...places]
|
|
505
|
+
if (things.length > 0) {
|
|
506
|
+
summary.push(`Reads ${things.slice(0, 5).join(', ')}${things.length > 5 ? ` and ${things.length - 5} more` : ''}${sending.size > 0 ? ', in code that can also reach the network' : ''}`)
|
|
507
|
+
if (secrets.size > 0) summary.push(`Secrets from your environment: ${[...secrets].join(', ')}`)
|
|
508
|
+
if (places.size > 0) summary.push(`Credential files and stores: ${[...places].join(', ')}`)
|
|
509
|
+
if (sending.size > 0) summary.push(`Worth a look: ${[...sending].slice(0, 4).join(', ')} can also send data over the network`)
|
|
510
|
+
summary.push(`In ${files.size} file${files.size === 1 ? '' : 's'}: ${[...files].slice(0, 4).join(', ')}${files.size > 4 ? ` and ${files.size - 4} more` : ''}`)
|
|
411
511
|
}
|
|
412
512
|
return row(
|
|
413
513
|
{
|
|
@@ -415,6 +515,7 @@ function credentialsCheck(ctx: ScanContext): CheckResult {
|
|
|
415
515
|
group: GROUP,
|
|
416
516
|
title: 'Credentials and secrets read by code',
|
|
417
517
|
advice: 'Code that reads your keys, tokens or browser data and can reach the network can steal them. Do not install it unless that is plainly its job.',
|
|
518
|
+
summary,
|
|
418
519
|
},
|
|
419
520
|
evidence,
|
|
420
521
|
)
|
|
@@ -506,7 +607,8 @@ function domainsCheck(ctx: ScanContext): CheckResult {
|
|
|
506
607
|
const authority = /\/\/([^/?#]{0,300})/.exec(match.text)?.[1] ?? ''
|
|
507
608
|
const hasUserInfo = authority.includes('@')
|
|
508
609
|
const host = (/(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+)/.exec(authority.slice(authority.lastIndexOf('@') + 1))?.[1] ?? '').toLowerCase()
|
|
509
|
-
|
|
610
|
+
// A one-word host (https://x) is a placeholder, not a place on the internet.
|
|
611
|
+
if (host === '' || (!host.includes('.') && !host.startsWith('['))) continue
|
|
510
612
|
if (hasUserInfo) evidence.push(judged(file, match, 'warn', `an address whose real host hides after "@": ${host}`))
|
|
511
613
|
if (/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|example\.(?:com|org|net))$/.test(host)) continue
|
|
512
614
|
if (!hosts.has(host)) hosts.set(host, { file: file.path, line: match.line })
|