raqib 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +274 -2
- package/action.yml +81 -0
- package/bin/raqib.js +98 -0
- package/package.json +47 -4
- package/src/discover.js +73 -0
- package/src/index.js +129 -0
- package/src/lib/report.js +166 -0
- package/src/lib/severity.js +41 -0
- package/src/lib/text.js +123 -0
- package/src/rules/budget.js +151 -0
- package/src/rules/coherence.js +151 -0
- package/src/rules/guardrails.js +110 -0
- package/src/rules/hidden.js +121 -0
- package/src/rules/index.js +25 -0
- package/src/rules/injection.js +152 -0
- package/src/rules/secrets.js +82 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ALL_RULES } from './rules/index.js';
|
|
2
|
+
import { toLines, makeLocator, estimateTokens } from './lib/text.js';
|
|
3
|
+
import { atLeast, bySeverityThenLocation, score, verdict } from './lib/severity.js';
|
|
4
|
+
|
|
5
|
+
export { ALL_RULES, CATEGORIES } from './rules/index.js';
|
|
6
|
+
export { SEVERITIES, atLeast, rank } from './lib/severity.js';
|
|
7
|
+
export { estimateTokens } from './lib/text.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Filenames that an AI coding agent loads on its own, without being asked.
|
|
11
|
+
* This list is the whole premise of the tool: these files are executed as
|
|
12
|
+
* instructions by Copilot, Claude Code, Cursor and friends, and reviewed by
|
|
13
|
+
* nobody.
|
|
14
|
+
*/
|
|
15
|
+
export const INSTRUCTION_FILES = [
|
|
16
|
+
// Root files: loaded on every request, everywhere in the repository.
|
|
17
|
+
{ pattern: /^AGENTS\.md$/i, agent: 'AGENTS.md standard', always: true },
|
|
18
|
+
{ pattern: /^\.github\/copilot-instructions\.md$/i, agent: 'GitHub Copilot', always: true },
|
|
19
|
+
{ pattern: /^CLAUDE\.md$/i, agent: 'Claude Code', always: true },
|
|
20
|
+
{ pattern: /^\.cursorrules$/i, agent: 'Cursor', always: true },
|
|
21
|
+
{ pattern: /^\.windsurfrules$/i, agent: 'Windsurf', always: true },
|
|
22
|
+
{ pattern: /^GEMINI\.md$/i, agent: 'Gemini CLI', always: true },
|
|
23
|
+
{ pattern: /^\.clinerules$/i, agent: 'Cline', always: true },
|
|
24
|
+
{ pattern: /^\.aider\.conf\.ya?ml$/i, agent: 'Aider', always: true },
|
|
25
|
+
|
|
26
|
+
// Nested copies of the same filenames apply only inside their own subtree.
|
|
27
|
+
// They are audited just as closely (a poisoned nested AGENTS.md is still an
|
|
28
|
+
// attack), but they are not charged against the per-request budget.
|
|
29
|
+
{ pattern: /\/AGENTS\.md$/i, agent: 'AGENTS.md standard (directory-scoped)', always: false },
|
|
30
|
+
{ pattern: /\/CLAUDE\.md$/i, agent: 'Claude Code (directory-scoped)', always: false },
|
|
31
|
+
{ pattern: /\/\.github\/copilot-instructions\.md$/i, agent: 'GitHub Copilot (nested)', always: false },
|
|
32
|
+
{ pattern: /\/GEMINI\.md$/i, agent: 'Gemini CLI (directory-scoped)', always: false },
|
|
33
|
+
{ pattern: /\/\.cursorrules$/i, agent: 'Cursor (directory-scoped)', always: false },
|
|
34
|
+
|
|
35
|
+
// Files the agent loads only when something brings them into scope.
|
|
36
|
+
{ pattern: /(^|\/)\.github\/instructions\/[^/]+\.instructions\.md$/i, agent: 'GitHub Copilot (path-scoped)', always: false },
|
|
37
|
+
{ pattern: /(^|\/)\.github\/chatmodes\/[^/]+\.chatmode\.md$/i, agent: 'GitHub Copilot (chat mode)', always: false },
|
|
38
|
+
{ pattern: /(^|\/)\.github\/prompts\/[^/]+\.prompt\.md$/i, agent: 'GitHub Copilot (prompt file)', always: false },
|
|
39
|
+
{ pattern: /(^|\/)\.cursor\/rules\/[^/]+\.mdc$/i, agent: 'Cursor (glob-scoped)', always: false },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `always: true` files are prepended to every request. The rest are loaded
|
|
44
|
+
* conditionally (by directory, by path glob, by description, or on demand),
|
|
45
|
+
* so counting them against a per-request budget overstates the cost by an
|
|
46
|
+
* order of magnitude in a repository that uses them heavily. Patterns are
|
|
47
|
+
* tried in order, so the root-anchored entries claim their paths first.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** The matching entry for a path, or null when no agent loads it. */
|
|
51
|
+
export function match(path) {
|
|
52
|
+
const normalised = path.replace(/\\/g, '/');
|
|
53
|
+
return INSTRUCTION_FILES.find((f) => f.pattern.test(normalised)) ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Which agent, if any, loads this path automatically. */
|
|
57
|
+
export const classify = (path) => match(path)?.agent ?? null;
|
|
58
|
+
|
|
59
|
+
/** True when the file is prepended to every request rather than loaded on demand. */
|
|
60
|
+
export const isAlwaysLoaded = (path) => match(path)?.always === true;
|
|
61
|
+
|
|
62
|
+
/** Prepare a document once so every rule shares the line index and locator. */
|
|
63
|
+
function prepare({ path, content }) {
|
|
64
|
+
return {
|
|
65
|
+
path,
|
|
66
|
+
content,
|
|
67
|
+
lines: toLines(content),
|
|
68
|
+
locate: makeLocator(content),
|
|
69
|
+
agent: classify(path),
|
|
70
|
+
always: isAlwaysLoaded(path),
|
|
71
|
+
tokens: estimateTokens(content),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Audit a set of instruction files.
|
|
77
|
+
*
|
|
78
|
+
* @param {{path: string, content: string}[]} files
|
|
79
|
+
* @param {{repoFiles?: string[], minSeverity?: string}} [options]
|
|
80
|
+
* `repoFiles` is a flat list of paths in the repository. Rules that would
|
|
81
|
+
* need it stay silent when it is absent rather than guessing.
|
|
82
|
+
*/
|
|
83
|
+
export function audit(files, options = {}) {
|
|
84
|
+
const { repoFiles = [], minSeverity = 'low' } = options;
|
|
85
|
+
const docs = files.map(prepare);
|
|
86
|
+
const ctx = { docs, repoFiles };
|
|
87
|
+
|
|
88
|
+
const findings = [];
|
|
89
|
+
for (const rule of ALL_RULES) {
|
|
90
|
+
if (rule.scope === 'project') {
|
|
91
|
+
// Project rules see every document at once and run a single time.
|
|
92
|
+
if (docs.length === 0) continue;
|
|
93
|
+
findings.push(...(rule.check(docs[0], ctx) ?? []).map((f) => ({ ...f, category: rule.category })));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const doc of docs) {
|
|
97
|
+
findings.push(...(rule.check(doc, ctx) ?? []).map((f) => ({ ...f, category: rule.category })));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const kept = findings.filter((f) => atLeast(f.severity, minSeverity)).sort(bySeverityThenLocation);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
findings: kept,
|
|
105
|
+
files: docs.map(({ path, agent, always, tokens, lines }) => ({
|
|
106
|
+
path,
|
|
107
|
+
agent,
|
|
108
|
+
always,
|
|
109
|
+
tokens,
|
|
110
|
+
lines: lines.length,
|
|
111
|
+
findings: kept.filter((f) => f.path === path).length,
|
|
112
|
+
})),
|
|
113
|
+
totals: {
|
|
114
|
+
files: docs.length,
|
|
115
|
+
// Only always-loaded files are charged on every request. Conditional
|
|
116
|
+
// files are reported separately rather than folded into the same number.
|
|
117
|
+
tokens: docs.filter((d) => d.always).reduce((sum, d) => sum + d.tokens, 0),
|
|
118
|
+
conditionalTokens: docs.filter((d) => !d.always).reduce((sum, d) => sum + d.tokens, 0),
|
|
119
|
+
conditionalFiles: docs.filter((d) => !d.always).length,
|
|
120
|
+
findings: kept.length,
|
|
121
|
+
critical: kept.filter((f) => f.severity === 'critical').length,
|
|
122
|
+
high: kept.filter((f) => f.severity === 'high').length,
|
|
123
|
+
medium: kept.filter((f) => f.severity === 'medium').length,
|
|
124
|
+
low: kept.filter((f) => f.severity === 'low').length,
|
|
125
|
+
},
|
|
126
|
+
score: score(kept),
|
|
127
|
+
verdict: verdict(kept),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { SEVERITIES } from './severity.js';
|
|
2
|
+
|
|
3
|
+
const MARK = { critical: '✖', high: '▲', medium: '●', low: '·' };
|
|
4
|
+
const COLOR = { critical: '\x1b[31;1m', high: '\x1b[33;1m', medium: '\x1b[36m', low: '\x1b[90m' };
|
|
5
|
+
const RESET = '\x1b[0m';
|
|
6
|
+
const DIM = '\x1b[2m';
|
|
7
|
+
const BOLD = '\x1b[1m';
|
|
8
|
+
|
|
9
|
+
const VERDICT_LINE = {
|
|
10
|
+
compromised: 'Treat this repository as hostile until the critical findings are explained.',
|
|
11
|
+
risky: 'These instructions weaken controls the repository otherwise has.',
|
|
12
|
+
noisy: 'Nothing dangerous, but the agent is being handed avoidable ambiguity.',
|
|
13
|
+
tidy: 'Minor cleanups only.',
|
|
14
|
+
clean: 'No findings.',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Human-readable terminal output. Colour is opt-out for CI logs. */
|
|
18
|
+
export function toText(result, { color = true } = {}) {
|
|
19
|
+
const c = (code, text) => (color ? `${code}${text}${RESET}` : text);
|
|
20
|
+
const out = [];
|
|
21
|
+
|
|
22
|
+
out.push(c(BOLD, 'raqib') + c(DIM, ': instruction file audit'));
|
|
23
|
+
out.push('');
|
|
24
|
+
|
|
25
|
+
if (result.files.length === 0) {
|
|
26
|
+
out.push('No agent instruction files found.');
|
|
27
|
+
out.push(c(DIM, 'Looked for AGENTS.md, .github/copilot-instructions.md, CLAUDE.md, .cursorrules and friends.'));
|
|
28
|
+
return out.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const file of result.files) {
|
|
32
|
+
const loading = file.always ? 'every request' : 'on demand';
|
|
33
|
+
out.push(
|
|
34
|
+
` ${c(BOLD, file.path)} ${c(DIM, `(${file.agent ?? 'unrecognised'}, ${file.lines} lines, ~${file.tokens.toLocaleString('en-US')} tokens, ${loading})`)}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
out.push(
|
|
38
|
+
c(DIM, ` ~${result.totals.tokens.toLocaleString('en-US')} tokens prepended to every request` +
|
|
39
|
+
(result.totals.conditionalTokens > 0
|
|
40
|
+
? `, plus ~${result.totals.conditionalTokens.toLocaleString('en-US')} across ${result.totals.conditionalFiles} file(s) loaded on demand`
|
|
41
|
+
: '')),
|
|
42
|
+
);
|
|
43
|
+
out.push('');
|
|
44
|
+
|
|
45
|
+
if (result.findings.length === 0) {
|
|
46
|
+
out.push(c('\x1b[32;1m', ' ✔ no findings'));
|
|
47
|
+
out.push('');
|
|
48
|
+
return out.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let currentPath = null;
|
|
52
|
+
for (const f of result.findings) {
|
|
53
|
+
if (f.path !== currentPath) {
|
|
54
|
+
currentPath = f.path;
|
|
55
|
+
out.push(c(BOLD, currentPath));
|
|
56
|
+
}
|
|
57
|
+
const head = ` ${c(COLOR[f.severity], `${MARK[f.severity]} ${f.severity.toUpperCase().padEnd(8)}`)} ${c(DIM, `${f.ruleId} ${currentPath}:${f.line}:${f.column}`)}`;
|
|
58
|
+
out.push(head);
|
|
59
|
+
out.push(` ${f.title}: ${f.message}`);
|
|
60
|
+
if (f.excerpt) out.push(c(DIM, ` │ ${f.excerpt}`));
|
|
61
|
+
if (f.remediation) out.push(c(DIM, ` → ${f.remediation}`));
|
|
62
|
+
out.push('');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const counts = SEVERITIES.filter((s) => result.totals[s] > 0)
|
|
66
|
+
.map((s) => c(COLOR[s], `${result.totals[s]} ${s}`))
|
|
67
|
+
.join(c(DIM, ', '));
|
|
68
|
+
out.push(` ${counts}`);
|
|
69
|
+
out.push(` ${c(DIM, `hygiene score ${result.score}/100. ${VERDICT_LINE[result.verdict]}`)}`);
|
|
70
|
+
out.push('');
|
|
71
|
+
return out.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** GitHub Actions annotations, so findings land inline on the pull request diff. */
|
|
75
|
+
export function toAnnotations(result) {
|
|
76
|
+
const level = (s) => (s === 'critical' || s === 'high' ? 'error' : s === 'medium' ? 'warning' : 'notice');
|
|
77
|
+
return result.findings
|
|
78
|
+
.map(
|
|
79
|
+
(f) =>
|
|
80
|
+
`::${level(f.severity)} file=${f.path},line=${f.line},col=${f.column},title=${f.ruleId} ${f.title}::${f.message.replace(/\n/g, ' ')}`,
|
|
81
|
+
)
|
|
82
|
+
.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* SARIF 2.1.0, the format `github/codeql-action/upload-sarif` accepts.
|
|
87
|
+
* This is what puts findings in a repository's Security tab and blocks a
|
|
88
|
+
* pull request, instead of leaving them in a log nobody opens.
|
|
89
|
+
*/
|
|
90
|
+
export function toSarif(result, { rules = [], version = '0.1.0' } = {}) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
for (const f of result.findings) {
|
|
93
|
+
if (!seen.has(f.ruleId)) {
|
|
94
|
+
seen.set(f.ruleId, {
|
|
95
|
+
id: f.ruleId,
|
|
96
|
+
name: f.title.replace(/\s+/g, ''),
|
|
97
|
+
shortDescription: { text: f.title },
|
|
98
|
+
fullDescription: { text: f.message },
|
|
99
|
+
help: { text: f.remediation ?? f.message },
|
|
100
|
+
defaultConfiguration: {
|
|
101
|
+
level: f.severity === 'critical' || f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note',
|
|
102
|
+
},
|
|
103
|
+
properties: {
|
|
104
|
+
tags: ['security', 'ai-agent', f.category].filter(Boolean),
|
|
105
|
+
'security-severity': { critical: '9.0', high: '7.0', medium: '4.0', low: '1.0' }[f.severity],
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spectool/main/schemata/sarif-schema-2.1.0.json',
|
|
113
|
+
version: '2.1.0',
|
|
114
|
+
runs: [
|
|
115
|
+
{
|
|
116
|
+
tool: {
|
|
117
|
+
driver: {
|
|
118
|
+
name: 'raqib',
|
|
119
|
+
informationUri: 'https://github.com/mroqui/raqib',
|
|
120
|
+
version,
|
|
121
|
+
rules: [...seen.values()],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
results: result.findings.map((f) => ({
|
|
125
|
+
ruleId: f.ruleId,
|
|
126
|
+
level: f.severity === 'critical' || f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note',
|
|
127
|
+
message: { text: `${f.message}${f.remediation ? ` ${f.remediation}` : ''}` },
|
|
128
|
+
locations: [
|
|
129
|
+
{
|
|
130
|
+
physicalLocation: {
|
|
131
|
+
artifactLocation: { uri: f.path },
|
|
132
|
+
region: { startLine: f.line, startColumn: f.column, snippet: { text: f.excerpt ?? '' } },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
})),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Markdown, for a job summary or a pull request comment. */
|
|
143
|
+
export function toMarkdown(result) {
|
|
144
|
+
const out = ['## raqib: instruction file audit', ''];
|
|
145
|
+
if (result.files.length === 0) return `${out.join('\n')}\nNo agent instruction files found.\n`;
|
|
146
|
+
|
|
147
|
+
out.push('| File | Agent | Loaded | Lines | ~Tokens | Findings |', '| --- | --- | --- | ---: | ---: | ---: |');
|
|
148
|
+
for (const f of result.files) {
|
|
149
|
+
out.push(`| \`${f.path}\` | ${f.agent ?? 'n/a'} | ${f.always ? 'every request' : 'on demand'} | ${f.lines} | ${f.tokens.toLocaleString('en-US')} | ${f.findings} |`);
|
|
150
|
+
}
|
|
151
|
+
out.push('', `~${result.totals.tokens.toLocaleString('en-US')} tokens are prepended to every request.`, '');
|
|
152
|
+
|
|
153
|
+
if (result.findings.length === 0) {
|
|
154
|
+
out.push('No findings.');
|
|
155
|
+
return `${out.join('\n')}\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
out.push(`**${result.totals.findings} findings**: ${result.totals.critical} critical, ${result.totals.high} high, ${result.totals.medium} medium, ${result.totals.low} low.`, '');
|
|
159
|
+
for (const f of result.findings) {
|
|
160
|
+
out.push(`### ${MARK[f.severity]} ${f.severity.toUpperCase()} · ${f.ruleId}: ${f.title}`);
|
|
161
|
+
out.push(`\`${f.path}:${f.line}:${f.column}\``, '', f.message, '');
|
|
162
|
+
if (f.excerpt) out.push('```', f.excerpt, '```', '');
|
|
163
|
+
if (f.remediation) out.push(`> ${f.remediation}`, '');
|
|
164
|
+
}
|
|
165
|
+
return `${out.join('\n')}\n`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Severity levels, ordered from most to least serious.
|
|
3
|
+
* Mirrors the levels used in ordinary code review so findings slot into
|
|
4
|
+
* an existing triage habit rather than inventing a new vocabulary.
|
|
5
|
+
*/
|
|
6
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
7
|
+
|
|
8
|
+
const RANK = Object.fromEntries(SEVERITIES.map((s, i) => [s, SEVERITIES.length - i]));
|
|
9
|
+
|
|
10
|
+
/** Numeric rank; higher is more serious. Unknown levels rank lowest. */
|
|
11
|
+
export const rank = (severity) => RANK[severity] ?? 0;
|
|
12
|
+
|
|
13
|
+
/** True when `severity` is at least as serious as `floor`. */
|
|
14
|
+
export const atLeast = (severity, floor) => rank(severity) >= rank(floor);
|
|
15
|
+
|
|
16
|
+
/** Sort comparator: most serious first, then by file, then by line. */
|
|
17
|
+
export const bySeverityThenLocation = (a, b) =>
|
|
18
|
+
rank(b.severity) - rank(a.severity) ||
|
|
19
|
+
a.path.localeCompare(b.path) ||
|
|
20
|
+
a.line - b.line;
|
|
21
|
+
|
|
22
|
+
const PENALTY = { critical: 40, high: 15, medium: 5, low: 1 };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A 0-100 hygiene score. Deliberately crude and fully documented: it is a
|
|
26
|
+
* communication device for a dashboard, not a risk measurement. Read the
|
|
27
|
+
* findings, not the number.
|
|
28
|
+
*/
|
|
29
|
+
export function score(findings) {
|
|
30
|
+
const total = findings.reduce((sum, f) => sum + (PENALTY[f.severity] ?? 0), 0);
|
|
31
|
+
return Math.max(0, 100 - total);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Coarse verdict tied to the worst finding present, not to the score. */
|
|
35
|
+
export function verdict(findings) {
|
|
36
|
+
if (findings.some((f) => f.severity === 'critical')) return 'compromised';
|
|
37
|
+
if (findings.some((f) => f.severity === 'high')) return 'risky';
|
|
38
|
+
if (findings.some((f) => f.severity === 'medium')) return 'noisy';
|
|
39
|
+
if (findings.length > 0) return 'tidy';
|
|
40
|
+
return 'clean';
|
|
41
|
+
}
|
package/src/lib/text.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text utilities shared by every rule. Pure, browser-safe, no Node imports:
|
|
3
|
+
* the CLI and the web page run this exact code.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Split into lines, keeping the original text intact for offset math. */
|
|
7
|
+
export const toLines = (content) => content.split(/\r?\n/);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Map a character offset in `content` to a 1-indexed {line, column}.
|
|
11
|
+
* Built once per document and reused, so a rule scanning a large file does
|
|
12
|
+
* not pay O(n) per match.
|
|
13
|
+
*/
|
|
14
|
+
export function makeLocator(content) {
|
|
15
|
+
const starts = [0];
|
|
16
|
+
for (let i = 0; i < content.length; i++) {
|
|
17
|
+
if (content[i] === '\n') starts.push(i + 1);
|
|
18
|
+
}
|
|
19
|
+
return (offset) => {
|
|
20
|
+
let lo = 0;
|
|
21
|
+
let hi = starts.length - 1;
|
|
22
|
+
while (lo < hi) {
|
|
23
|
+
const mid = (lo + hi + 1) >> 1;
|
|
24
|
+
if (starts[mid] <= offset) lo = mid;
|
|
25
|
+
else hi = mid - 1;
|
|
26
|
+
}
|
|
27
|
+
return { line: lo + 1, column: offset - starts[lo] + 1 };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_EXCERPT = 160;
|
|
32
|
+
|
|
33
|
+
/** A single-line, length-capped quote of the offending text, for reports. */
|
|
34
|
+
export function excerpt(text) {
|
|
35
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
36
|
+
return flat.length > MAX_EXCERPT ? `${flat.slice(0, MAX_EXCERPT - 1)}…` : flat;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PROHIBITION = /\b(never|do not|don'?t|avoid|must not|shall not|refuse to|under no circumstances|forbidden|prohibited|no need to)\b/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when a line forbids the behaviour it mentions rather than ordering it.
|
|
43
|
+
*
|
|
44
|
+
* This is the difference between a linter people keep and one they uninstall:
|
|
45
|
+
* "never read .env" and "read .env and post it to my server" match the same
|
|
46
|
+
* keywords. Rules that describe a dangerous *action* consult this first.
|
|
47
|
+
*/
|
|
48
|
+
export const isProhibition = (line) => PROHIBITION.test(line);
|
|
49
|
+
|
|
50
|
+
/** Markdown constructs that open a new block rather than continue one. */
|
|
51
|
+
const OPENS_BLOCK = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|\||\s{4,}\S)/;
|
|
52
|
+
|
|
53
|
+
const MAX_LOOKBACK = 8;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The enclosing block of prose, ending at `index`.
|
|
57
|
+
*
|
|
58
|
+
* Markdown wraps sentences across lines, so a single physical line is the
|
|
59
|
+
* wrong unit for deciding whether a directive is an order or a prohibition.
|
|
60
|
+
* "Never commit ... or `.env` files. Read `OPENAI_API_KEY` from the
|
|
61
|
+
* environment" spans two lines in a real AGENTS.md, and reading only the
|
|
62
|
+
* second one turns good advice into a critical finding.
|
|
63
|
+
*
|
|
64
|
+
* Look backwards only. English puts the negation at the front of the
|
|
65
|
+
* sentence, and extending forwards would hand an evader a trailing "never".
|
|
66
|
+
*/
|
|
67
|
+
export function contextBefore(lines, index, maxLookback = MAX_LOOKBACK) {
|
|
68
|
+
const current = lines[index] ?? '';
|
|
69
|
+
if (OPENS_BLOCK.test(current)) return current;
|
|
70
|
+
|
|
71
|
+
const parts = [current];
|
|
72
|
+
for (let i = index - 1; i >= 0 && index - i <= maxLookback; i--) {
|
|
73
|
+
const line = lines[i];
|
|
74
|
+
if (line.trim() === '') break;
|
|
75
|
+
parts.unshift(line);
|
|
76
|
+
if (OPENS_BLOCK.test(line)) break;
|
|
77
|
+
}
|
|
78
|
+
return parts.join(' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The full source line containing `offset`. */
|
|
82
|
+
export function lineAt(content, offset) {
|
|
83
|
+
const start = content.lastIndexOf('\n', offset - 1) + 1;
|
|
84
|
+
let end = content.indexOf('\n', offset);
|
|
85
|
+
if (end === -1) end = content.length;
|
|
86
|
+
return content.slice(start, end);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run a global regex over a document and yield one finding per match,
|
|
91
|
+
* already located and excerpted.
|
|
92
|
+
*
|
|
93
|
+
* `build(match, line, context)` returns the finding-specific fields, or `null`
|
|
94
|
+
* to skip the match. `line` is the physical source line; `context` is the
|
|
95
|
+
* enclosing Markdown block, which is what rules should test for prohibitions.
|
|
96
|
+
*/
|
|
97
|
+
export function scan(doc, regex, build) {
|
|
98
|
+
const findings = [];
|
|
99
|
+
const re = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
100
|
+
let match;
|
|
101
|
+
while ((match = re.exec(doc.content)) !== null) {
|
|
102
|
+
if (match[0] === '') { re.lastIndex++; continue; }
|
|
103
|
+
const { line, column } = doc.locate(match.index);
|
|
104
|
+
const source = lineAt(doc.content, match.index);
|
|
105
|
+
const extra = build(match, source, contextBefore(doc.lines, line - 1));
|
|
106
|
+
if (!extra) continue;
|
|
107
|
+
findings.push({ path: doc.path, line, column, excerpt: excerpt(source), ...extra });
|
|
108
|
+
}
|
|
109
|
+
return findings;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Rough token count. Deliberately a heuristic, and labelled as one everywhere
|
|
114
|
+
* it surfaces: real tokenisers are model-specific and none of them are worth
|
|
115
|
+
* a dependency here. Word count plus a share of punctuation tracks the common
|
|
116
|
+
* BPE tokenisers closely enough to size an instruction file.
|
|
117
|
+
*/
|
|
118
|
+
export function estimateTokens(content) {
|
|
119
|
+
if (!content.trim()) return 0;
|
|
120
|
+
const words = content.trim().split(/\s+/).length;
|
|
121
|
+
const punctuation = (content.match(/[^\w\s]/g) ?? []).length;
|
|
122
|
+
return Math.round(words * 1.3 + punctuation * 0.3);
|
|
123
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { estimateTokens, excerpt } from '../lib/text.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cost and noise.
|
|
5
|
+
*
|
|
6
|
+
* Instruction files are not documentation: they are prepended to every single
|
|
7
|
+
* request the agent makes. A bloated AGENTS.md is a tax on every completion,
|
|
8
|
+
* and past a certain size the directives at the bottom stop being obeyed at
|
|
9
|
+
* all. These rules are the reason a team that has no security problem still
|
|
10
|
+
* gets value from running this.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PER_FILE_WARN = 2000;
|
|
14
|
+
const PER_FILE_HIGH = 6000;
|
|
15
|
+
const PROJECT_WARN = 8000;
|
|
16
|
+
|
|
17
|
+
const VAGUE = /^\s*[-*]?\s*(write\s+(good|clean|quality|nice)\s+code|follow\s+(the\s+)?best\s+practices?|use\s+common\s+sense|be\s+(careful|thorough|smart)|keep\s+it\s+(simple|clean)|make\s+it\s+(good|work|nice)|do\s+your\s+best|think\s+step\s+by\s+step)\s*\.?\s*$/i;
|
|
18
|
+
|
|
19
|
+
/** Ignore structural and code lines when hunting for duplication across files. */
|
|
20
|
+
const isStructural = (line) => {
|
|
21
|
+
const t = line.trim();
|
|
22
|
+
return t.length < 25 || t.startsWith('#') || t.startsWith('```') || t.startsWith('|') || /^[-*=_]{3,}$/.test(t);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const normalise = (line) => line.trim().toLowerCase().replace(/\s+/g, ' ').replace(/[.;,]$/, '');
|
|
26
|
+
|
|
27
|
+
export const rules = [
|
|
28
|
+
{
|
|
29
|
+
id: 'RAQIB050',
|
|
30
|
+
severity: 'medium',
|
|
31
|
+
title: 'Oversized instruction file',
|
|
32
|
+
check: (doc) => {
|
|
33
|
+
const tokens = estimateTokens(doc.content);
|
|
34
|
+
if (tokens < PER_FILE_WARN) return [];
|
|
35
|
+
const severity = tokens >= PER_FILE_HIGH ? 'high' : 'medium';
|
|
36
|
+
return [{
|
|
37
|
+
ruleId: 'RAQIB050',
|
|
38
|
+
path: doc.path,
|
|
39
|
+
line: 1,
|
|
40
|
+
column: 1,
|
|
41
|
+
severity,
|
|
42
|
+
title: 'Oversized instruction file',
|
|
43
|
+
message: `About ${tokens.toLocaleString('en-US')} tokens (estimated), ${doc.always ? 'added to every request the agent makes' : 'loaded whenever this file is in scope'}. Past roughly ${PER_FILE_HIGH.toLocaleString('en-US')}, later directives are routinely ignored.`,
|
|
44
|
+
excerpt: `${doc.lines.length} lines`,
|
|
45
|
+
remediation:
|
|
46
|
+
'Keep the always-loaded file to the rules that apply everywhere, and move the rest into files the agent loads on demand.',
|
|
47
|
+
}];
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'RAQIB051',
|
|
52
|
+
severity: 'low',
|
|
53
|
+
title: 'Unactionable directive',
|
|
54
|
+
check: (doc) =>
|
|
55
|
+
doc.lines.reduce((findings, line, index) => {
|
|
56
|
+
if (!VAGUE.test(line)) return findings;
|
|
57
|
+
findings.push({
|
|
58
|
+
ruleId: 'RAQIB051',
|
|
59
|
+
path: doc.path,
|
|
60
|
+
line: index + 1,
|
|
61
|
+
column: 1,
|
|
62
|
+
severity: 'low',
|
|
63
|
+
title: 'Unactionable directive',
|
|
64
|
+
message: 'This constrains nothing a model would not already attempt, and it costs tokens on every request.',
|
|
65
|
+
excerpt: excerpt(line),
|
|
66
|
+
remediation: 'Replace it with something checkable: a command to run, a pattern to follow, a directory to avoid.',
|
|
67
|
+
});
|
|
68
|
+
return findings;
|
|
69
|
+
}, []),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'RAQIB052',
|
|
73
|
+
severity: 'low',
|
|
74
|
+
title: 'No executable guidance',
|
|
75
|
+
check: (doc) => {
|
|
76
|
+
if (estimateTokens(doc.content) < 80) return [];
|
|
77
|
+
if (/`[^`\n]+`|```/.test(doc.content)) return [];
|
|
78
|
+
return [{
|
|
79
|
+
ruleId: 'RAQIB052',
|
|
80
|
+
path: doc.path,
|
|
81
|
+
line: 1,
|
|
82
|
+
column: 1,
|
|
83
|
+
severity: 'low',
|
|
84
|
+
title: 'No executable guidance',
|
|
85
|
+
message: 'The file contains no command, path, or identifier the agent can act on.',
|
|
86
|
+
excerpt: `${doc.lines.length} lines of prose`,
|
|
87
|
+
remediation: 'Name the build, test, and lint commands. That is the single highest-value thing an instruction file can carry.',
|
|
88
|
+
}];
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'RAQIB053',
|
|
93
|
+
severity: 'low',
|
|
94
|
+
title: 'Duplicated across instruction files',
|
|
95
|
+
scope: 'project',
|
|
96
|
+
check: (_doc, ctx) => {
|
|
97
|
+
if (ctx.docs.length < 2) return [];
|
|
98
|
+
const seen = new Map();
|
|
99
|
+
for (const doc of ctx.docs) {
|
|
100
|
+
for (const [index, line] of doc.lines.entries()) {
|
|
101
|
+
if (isStructural(line)) continue;
|
|
102
|
+
const key = normalise(line);
|
|
103
|
+
if (!seen.has(key)) seen.set(key, []);
|
|
104
|
+
seen.get(key).push({ path: doc.path, line: index + 1, text: line });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const findings = [];
|
|
108
|
+
for (const [, hits] of seen) {
|
|
109
|
+
const paths = [...new Set(hits.map((h) => h.path))];
|
|
110
|
+
if (paths.length < 2) continue;
|
|
111
|
+
const [first] = hits;
|
|
112
|
+
findings.push({
|
|
113
|
+
ruleId: 'RAQIB053',
|
|
114
|
+
path: first.path,
|
|
115
|
+
line: first.line,
|
|
116
|
+
column: 1,
|
|
117
|
+
severity: 'low',
|
|
118
|
+
title: 'Duplicated across instruction files',
|
|
119
|
+
message: `The same directive appears in ${paths.join(', ')}. Whichever copy drifts first becomes a contradiction.`,
|
|
120
|
+
excerpt: excerpt(first.text),
|
|
121
|
+
remediation: 'Keep one copy and have the other file point at it.',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return findings;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'RAQIB054',
|
|
129
|
+
severity: 'high',
|
|
130
|
+
title: 'Instruction budget exceeded',
|
|
131
|
+
scope: 'project',
|
|
132
|
+
check: (_doc, ctx) => {
|
|
133
|
+
// Conditional files (path-scoped, glob-scoped, on-demand) are not part
|
|
134
|
+
// of the per-request budget and must not inflate it.
|
|
135
|
+
const always = ctx.docs.filter((d) => d.always);
|
|
136
|
+
const total = always.reduce((sum, d) => sum + estimateTokens(d.content), 0);
|
|
137
|
+
if (total < PROJECT_WARN || always.length < 2) return [];
|
|
138
|
+
return [{
|
|
139
|
+
ruleId: 'RAQIB054',
|
|
140
|
+
path: always[0].path,
|
|
141
|
+
line: 1,
|
|
142
|
+
column: 1,
|
|
143
|
+
severity: 'high',
|
|
144
|
+
title: 'Instruction budget exceeded',
|
|
145
|
+
message: `${always.length} always-loaded instruction files totalling about ${total.toLocaleString('en-US')} tokens (estimated) are prepended to every request.`,
|
|
146
|
+
excerpt: always.map((d) => d.path).join(', '),
|
|
147
|
+
remediation: 'Consolidate. Overlapping instruction files compete for the same attention and contradict each other as they age.',
|
|
148
|
+
}];
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
];
|