facult 2.6.0 → 2.7.1
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/README.md +145 -337
- package/package.json +1 -1
- package/src/adapters/codex.ts +1 -1
- package/src/audit/agent.ts +26 -24
- package/src/audit/fix.ts +875 -0
- package/src/audit/index.ts +51 -2
- package/src/audit/safe.ts +596 -0
- package/src/audit/static.ts +151 -34
- package/src/audit/status.ts +21 -0
- package/src/audit/suppressions.ts +266 -0
- package/src/audit/tui.ts +784 -174
- package/src/audit/update-index.ts +4 -17
- package/src/builtin.ts +7 -1
- package/src/cli-ui.ts +375 -0
- package/src/consolidate.ts +151 -55
- package/src/doctor.ts +327 -0
- package/src/global-docs.ts +43 -2
- package/src/index.ts +571 -292
- package/src/manage.ts +931 -88
- package/src/mcp-config.ts +132 -0
- package/src/project-sync.ts +288 -0
- package/src/remote.ts +387 -117
- package/src/trust.ts +119 -11
- package/src/util/git.ts +95 -0
package/src/audit/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { agentAuditCommand } from "./agent";
|
|
2
|
+
import { auditFixCommand } from "./fix";
|
|
3
|
+
import { auditSafeCommand } from "./safe";
|
|
2
4
|
import { staticAuditCommand } from "./static";
|
|
3
5
|
import { auditTuiCommand } from "./tui";
|
|
4
6
|
|
|
@@ -7,6 +9,8 @@ function printHelp() {
|
|
|
7
9
|
|
|
8
10
|
Usage:
|
|
9
11
|
fclt audit [--from <path>] [--no-config-from]
|
|
12
|
+
fclt audit fix <item> [--path <path>] [--source <static|agent|combined>]
|
|
13
|
+
fclt audit safe <item> [--rule <id>] [--location <text>] [--message <text>]
|
|
10
14
|
fclt audit --non-interactive [name|mcp:<name>] [--severity <level>] [--rules <path>] [--from <path>] [--json]
|
|
11
15
|
fclt audit --non-interactive [name|mcp:<name>] --with <claude|codex> [--from <path>] [--max-items <n|all>] [--json]
|
|
12
16
|
|
|
@@ -18,13 +22,36 @@ Legacy (still supported; prefer --non-interactive):
|
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export async function auditCommand(argv: string[]) {
|
|
25
|
+
const firstPositional = argv.find((a) => a && !a.startsWith("-")) ?? null;
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
(argv.includes("--help") || argv.includes("-h")) &&
|
|
29
|
+
firstPositional === "fix"
|
|
30
|
+
) {
|
|
31
|
+
await auditFixCommand(argv.slice(1));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
(argv.includes("--help") || argv.includes("-h")) &&
|
|
36
|
+
firstPositional === "safe"
|
|
37
|
+
) {
|
|
38
|
+
await auditSafeCommand(argv.slice(1));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (argv[0] === "help" && (argv[1] === "fix" || argv[1] === "safe")) {
|
|
42
|
+
if (argv[1] === "fix") {
|
|
43
|
+
await auditFixCommand(["--help"]);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await auditSafeCommand(["--help"]);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
21
49
|
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
22
50
|
printHelp();
|
|
23
51
|
return;
|
|
24
52
|
}
|
|
25
53
|
|
|
26
54
|
const nonInteractive = argv.includes("--non-interactive");
|
|
27
|
-
const firstPositional = argv.find((a) => a && !a.startsWith("-")) ?? null;
|
|
28
55
|
|
|
29
56
|
const rest = argv.filter((a) => a !== "--non-interactive");
|
|
30
57
|
|
|
@@ -32,10 +59,24 @@ export async function auditCommand(argv: string[]) {
|
|
|
32
59
|
// Optional: allow `fclt audit --non-interactive static ...` / `... agent ...`
|
|
33
60
|
const sub = firstPositional;
|
|
34
61
|
const subArgs =
|
|
35
|
-
sub === "static" ||
|
|
62
|
+
sub === "static" ||
|
|
63
|
+
sub === "agent" ||
|
|
64
|
+
sub === "tui" ||
|
|
65
|
+
sub === "wizard" ||
|
|
66
|
+
sub === "fix" ||
|
|
67
|
+
sub === "safe"
|
|
36
68
|
? rest.slice(1)
|
|
37
69
|
: rest;
|
|
38
70
|
|
|
71
|
+
if (sub === "fix") {
|
|
72
|
+
await auditFixCommand(subArgs);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (sub === "safe") {
|
|
76
|
+
await auditSafeCommand(subArgs);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
const hasWith =
|
|
40
81
|
subArgs.includes("--with") ||
|
|
41
82
|
subArgs.some((a) => a.startsWith("--with="));
|
|
@@ -68,6 +109,14 @@ export async function auditCommand(argv: string[]) {
|
|
|
68
109
|
await auditTuiCommand(argv.slice(1));
|
|
69
110
|
return;
|
|
70
111
|
}
|
|
112
|
+
if (firstPositional === "safe") {
|
|
113
|
+
await auditSafeCommand(argv.slice(1));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (firstPositional === "fix") {
|
|
117
|
+
await auditFixCommand(argv.slice(1));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
71
120
|
|
|
72
121
|
// Default: interactive wizard.
|
|
73
122
|
await auditTuiCommand(argv);
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { facultStateDir } from "../paths";
|
|
4
|
+
import type { AgentAuditReport } from "./agent";
|
|
5
|
+
import {
|
|
6
|
+
applyAuditSuppressionsToAgentReport,
|
|
7
|
+
applyAuditSuppressionsToStaticReport,
|
|
8
|
+
loadAuditSuppressions,
|
|
9
|
+
recordAuditSuppressions,
|
|
10
|
+
} from "./suppressions";
|
|
11
|
+
import type {
|
|
12
|
+
AuditFinding,
|
|
13
|
+
AuditItemResult,
|
|
14
|
+
Severity,
|
|
15
|
+
StaticAuditReport,
|
|
16
|
+
} from "./types";
|
|
17
|
+
import { updateIndexFromAuditReport } from "./update-index";
|
|
18
|
+
|
|
19
|
+
type AuditSafeSource = "static" | "agent" | "combined";
|
|
20
|
+
const ARG_VALUE_SPLIT_RE = /=(.*)/s;
|
|
21
|
+
|
|
22
|
+
interface AuditSafeArgs {
|
|
23
|
+
all: boolean;
|
|
24
|
+
dryRun: boolean;
|
|
25
|
+
itemSelectors: string[];
|
|
26
|
+
json: boolean;
|
|
27
|
+
locations: string[];
|
|
28
|
+
messages: string[];
|
|
29
|
+
note?: string;
|
|
30
|
+
paths: string[];
|
|
31
|
+
rules: string[];
|
|
32
|
+
severity?: Severity;
|
|
33
|
+
source?: AuditSafeSource;
|
|
34
|
+
yes: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface FindingSelection {
|
|
38
|
+
result: AuditItemResult;
|
|
39
|
+
finding: AuditFinding;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const RULE_ID_PREFIX_RE = /^(static|agent):/;
|
|
43
|
+
|
|
44
|
+
function normalizeRuleId(ruleId: string): string {
|
|
45
|
+
return ruleId.replace(RULE_ID_PREFIX_RE, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseSource(value: string): AuditSafeSource {
|
|
49
|
+
const normalized = value.trim().toLowerCase();
|
|
50
|
+
if (
|
|
51
|
+
normalized === "static" ||
|
|
52
|
+
normalized === "agent" ||
|
|
53
|
+
normalized === "combined"
|
|
54
|
+
) {
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Unknown audit safe source: ${value}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseSeverity(value: string): Severity {
|
|
61
|
+
const normalized = value.trim().toLowerCase();
|
|
62
|
+
if (
|
|
63
|
+
normalized === "low" ||
|
|
64
|
+
normalized === "medium" ||
|
|
65
|
+
normalized === "high" ||
|
|
66
|
+
normalized === "critical"
|
|
67
|
+
) {
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`Unknown severity: ${value}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseAuditSafeArgs(argv: string[]): AuditSafeArgs {
|
|
74
|
+
const args: AuditSafeArgs = {
|
|
75
|
+
all: false,
|
|
76
|
+
dryRun: false,
|
|
77
|
+
itemSelectors: [],
|
|
78
|
+
json: false,
|
|
79
|
+
locations: [],
|
|
80
|
+
messages: [],
|
|
81
|
+
paths: [],
|
|
82
|
+
rules: [],
|
|
83
|
+
yes: false,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
87
|
+
const arg = argv[i];
|
|
88
|
+
if (!arg) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (arg === "--all") {
|
|
93
|
+
args.all = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (arg === "--dry-run") {
|
|
97
|
+
args.dryRun = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (arg === "--json") {
|
|
101
|
+
args.json = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (arg === "--yes" || arg === "-y") {
|
|
105
|
+
args.yes = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (arg === "--source" || arg === "--item" || arg === "--path") {
|
|
110
|
+
const next = argv[i + 1];
|
|
111
|
+
if (!next) {
|
|
112
|
+
throw new Error(`${arg} requires a value`);
|
|
113
|
+
}
|
|
114
|
+
if (arg === "--source") {
|
|
115
|
+
args.source = parseSource(next);
|
|
116
|
+
} else if (arg === "--item") {
|
|
117
|
+
args.itemSelectors.push(next);
|
|
118
|
+
} else {
|
|
119
|
+
args.paths.push(next);
|
|
120
|
+
}
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
arg === "--rule" ||
|
|
127
|
+
arg === "--location" ||
|
|
128
|
+
arg === "--message" ||
|
|
129
|
+
arg === "--note" ||
|
|
130
|
+
arg === "--severity"
|
|
131
|
+
) {
|
|
132
|
+
const next = argv[i + 1];
|
|
133
|
+
if (!next) {
|
|
134
|
+
throw new Error(`${arg} requires a value`);
|
|
135
|
+
}
|
|
136
|
+
if (arg === "--rule") {
|
|
137
|
+
args.rules.push(next);
|
|
138
|
+
} else if (arg === "--location") {
|
|
139
|
+
args.locations.push(next);
|
|
140
|
+
} else if (arg === "--message") {
|
|
141
|
+
args.messages.push(next);
|
|
142
|
+
} else if (arg === "--note") {
|
|
143
|
+
args.note = next;
|
|
144
|
+
} else {
|
|
145
|
+
args.severity = parseSeverity(next);
|
|
146
|
+
}
|
|
147
|
+
i += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
arg.startsWith("--source=") ||
|
|
153
|
+
arg.startsWith("--item=") ||
|
|
154
|
+
arg.startsWith("--path=") ||
|
|
155
|
+
arg.startsWith("--rule=") ||
|
|
156
|
+
arg.startsWith("--location=") ||
|
|
157
|
+
arg.startsWith("--message=") ||
|
|
158
|
+
arg.startsWith("--note=") ||
|
|
159
|
+
arg.startsWith("--severity=")
|
|
160
|
+
) {
|
|
161
|
+
const [flag, rawValue] = arg.split(ARG_VALUE_SPLIT_RE, 2);
|
|
162
|
+
const value = rawValue ?? "";
|
|
163
|
+
if (!value) {
|
|
164
|
+
throw new Error(`${flag} requires a value`);
|
|
165
|
+
}
|
|
166
|
+
if (flag === "--source") {
|
|
167
|
+
args.source = parseSource(value);
|
|
168
|
+
} else if (flag === "--item") {
|
|
169
|
+
args.itemSelectors.push(value);
|
|
170
|
+
} else if (flag === "--path") {
|
|
171
|
+
args.paths.push(value);
|
|
172
|
+
} else if (flag === "--rule") {
|
|
173
|
+
args.rules.push(value);
|
|
174
|
+
} else if (flag === "--location") {
|
|
175
|
+
args.locations.push(value);
|
|
176
|
+
} else if (flag === "--message") {
|
|
177
|
+
args.messages.push(value);
|
|
178
|
+
} else if (flag === "--note") {
|
|
179
|
+
args.note = value;
|
|
180
|
+
} else if (flag === "--severity") {
|
|
181
|
+
args.severity = parseSeverity(value);
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (arg.startsWith("-")) {
|
|
187
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
args.itemSelectors.push(arg);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
!args.all &&
|
|
195
|
+
args.itemSelectors.length === 0 &&
|
|
196
|
+
args.paths.length === 0 &&
|
|
197
|
+
args.rules.length === 0 &&
|
|
198
|
+
args.locations.length === 0 &&
|
|
199
|
+
args.messages.length === 0 &&
|
|
200
|
+
!args.severity
|
|
201
|
+
) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
"Specify what to suppress with --item, --rule, --path, --location, --message, --severity, or use --all."
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return args;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function uniqueByKey<T>(items: T[], key: (value: T) => string): T[] {
|
|
211
|
+
const seen = new Set<string>();
|
|
212
|
+
const out: T[] = [];
|
|
213
|
+
for (const item of items) {
|
|
214
|
+
const itemKey = key(item);
|
|
215
|
+
if (seen.has(itemKey)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
seen.add(itemKey);
|
|
219
|
+
out.push(item);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function keyForResult(result: AuditItemResult): string {
|
|
225
|
+
return `${result.type}\0${result.item}\0${result.path}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function prefixRuleId(
|
|
229
|
+
finding: AuditFinding,
|
|
230
|
+
prefix: "static" | "agent"
|
|
231
|
+
): AuditFinding {
|
|
232
|
+
return finding.ruleId.startsWith(`${prefix}:`)
|
|
233
|
+
? finding
|
|
234
|
+
: { ...finding, ruleId: `${prefix}:${finding.ruleId}` };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function mergeStaticAndAgentResults(args: {
|
|
238
|
+
static: AuditItemResult[];
|
|
239
|
+
agent: AuditItemResult[];
|
|
240
|
+
}): AuditItemResult[] {
|
|
241
|
+
const byKey = new Map<
|
|
242
|
+
string,
|
|
243
|
+
{ static?: AuditItemResult; agent?: AuditItemResult }
|
|
244
|
+
>();
|
|
245
|
+
|
|
246
|
+
for (const result of args.static) {
|
|
247
|
+
const key = keyForResult(result);
|
|
248
|
+
const previous = byKey.get(key) ?? {};
|
|
249
|
+
byKey.set(key, { ...previous, static: result });
|
|
250
|
+
}
|
|
251
|
+
for (const result of args.agent) {
|
|
252
|
+
const key = keyForResult(result);
|
|
253
|
+
const previous = byKey.get(key) ?? {};
|
|
254
|
+
byKey.set(key, { ...previous, agent: result });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const out: AuditItemResult[] = [];
|
|
258
|
+
for (const key of [...byKey.keys()].sort()) {
|
|
259
|
+
const entry = byKey.get(key);
|
|
260
|
+
if (!entry) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (entry.static && entry.agent) {
|
|
264
|
+
out.push({
|
|
265
|
+
...entry.agent,
|
|
266
|
+
passed: entry.static.passed && entry.agent.passed,
|
|
267
|
+
findings: [
|
|
268
|
+
...entry.agent.findings.map((finding) =>
|
|
269
|
+
prefixRuleId(finding, "agent")
|
|
270
|
+
),
|
|
271
|
+
...entry.static.findings.map((finding) =>
|
|
272
|
+
prefixRuleId(finding, "static")
|
|
273
|
+
),
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
out.push(entry.agent ?? entry.static!);
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function loadLatestStaticReport(
|
|
284
|
+
homeDir: string
|
|
285
|
+
): Promise<StaticAuditReport | null> {
|
|
286
|
+
const path = join(facultStateDir(homeDir), "audit", "static-latest.json");
|
|
287
|
+
const file = Bun.file(path);
|
|
288
|
+
if (!(await file.exists())) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return (await file.json()) as StaticAuditReport;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function loadLatestAgentReport(
|
|
295
|
+
homeDir: string
|
|
296
|
+
): Promise<AgentAuditReport | null> {
|
|
297
|
+
const path = join(facultStateDir(homeDir), "audit", "agent-latest.json");
|
|
298
|
+
const file = Bun.file(path);
|
|
299
|
+
if (!(await file.exists())) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
return (await file.json()) as AgentAuditReport;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function inferSource(args: {
|
|
306
|
+
requested?: AuditSafeSource;
|
|
307
|
+
staticReport: StaticAuditReport | null;
|
|
308
|
+
agentReport: AgentAuditReport | null;
|
|
309
|
+
}): AuditSafeSource {
|
|
310
|
+
if (args.requested) {
|
|
311
|
+
return args.requested;
|
|
312
|
+
}
|
|
313
|
+
if (args.staticReport && args.agentReport) {
|
|
314
|
+
return "combined";
|
|
315
|
+
}
|
|
316
|
+
if (args.agentReport) {
|
|
317
|
+
return "agent";
|
|
318
|
+
}
|
|
319
|
+
return "static";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function matchesItemSelector(
|
|
323
|
+
result: AuditItemResult,
|
|
324
|
+
selector: string
|
|
325
|
+
): boolean {
|
|
326
|
+
const normalized = selector.trim();
|
|
327
|
+
if (!normalized) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
const labels = [
|
|
331
|
+
result.item,
|
|
332
|
+
`${result.type}:${result.item}`,
|
|
333
|
+
result.type === "mcp" ? `mcp:${result.item}` : null,
|
|
334
|
+
result.type === "skill" ? `skill:${result.item}` : null,
|
|
335
|
+
basename(result.path),
|
|
336
|
+
].filter(Boolean) as string[];
|
|
337
|
+
return labels.some(
|
|
338
|
+
(label) => label.toLowerCase() === normalized.toLowerCase()
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function matchesPath(result: AuditItemResult, candidate: string): boolean {
|
|
343
|
+
const normalized = candidate.trim().toLowerCase();
|
|
344
|
+
if (!normalized) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
const path = result.path.toLowerCase();
|
|
348
|
+
return path === normalized || path.endsWith(`/${normalized}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function matchesFinding(args: {
|
|
352
|
+
result: AuditItemResult;
|
|
353
|
+
finding: AuditFinding;
|
|
354
|
+
filters: AuditSafeArgs;
|
|
355
|
+
}): boolean {
|
|
356
|
+
if (
|
|
357
|
+
args.filters.itemSelectors.length > 0 &&
|
|
358
|
+
!args.filters.itemSelectors.some((selector) =>
|
|
359
|
+
matchesItemSelector(args.result, selector)
|
|
360
|
+
)
|
|
361
|
+
) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (
|
|
366
|
+
args.filters.paths.length > 0 &&
|
|
367
|
+
!args.filters.paths.some((candidate) => matchesPath(args.result, candidate))
|
|
368
|
+
) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (
|
|
373
|
+
args.filters.rules.length > 0 &&
|
|
374
|
+
!args.filters.rules.some((rule) => {
|
|
375
|
+
const normalizedRule = rule.trim().toLowerCase();
|
|
376
|
+
return (
|
|
377
|
+
args.finding.ruleId.toLowerCase() === normalizedRule ||
|
|
378
|
+
normalizeRuleId(args.finding.ruleId).toLowerCase() === normalizedRule
|
|
379
|
+
);
|
|
380
|
+
})
|
|
381
|
+
) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (
|
|
386
|
+
args.filters.locations.length > 0 &&
|
|
387
|
+
!args.filters.locations.some((location) =>
|
|
388
|
+
(args.finding.location ?? "")
|
|
389
|
+
.toLowerCase()
|
|
390
|
+
.includes(location.toLowerCase())
|
|
391
|
+
)
|
|
392
|
+
) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (
|
|
397
|
+
args.filters.messages.length > 0 &&
|
|
398
|
+
!args.filters.messages.some((message) =>
|
|
399
|
+
args.finding.message.toLowerCase().includes(message.toLowerCase())
|
|
400
|
+
)
|
|
401
|
+
) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (
|
|
406
|
+
args.filters.severity &&
|
|
407
|
+
args.finding.severity.toLowerCase() !== args.filters.severity
|
|
408
|
+
) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function rewriteLatestReports(args: {
|
|
416
|
+
homeDir: string;
|
|
417
|
+
staticReport: StaticAuditReport | null;
|
|
418
|
+
agentReport: AgentAuditReport | null;
|
|
419
|
+
}) {
|
|
420
|
+
const auditDir = join(facultStateDir(args.homeDir), "audit");
|
|
421
|
+
if (args.staticReport) {
|
|
422
|
+
await Bun.write(
|
|
423
|
+
join(auditDir, "static-latest.json"),
|
|
424
|
+
`${JSON.stringify(args.staticReport, null, 2)}\n`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (args.agentReport) {
|
|
428
|
+
await Bun.write(
|
|
429
|
+
join(auditDir, "agent-latest.json"),
|
|
430
|
+
`${JSON.stringify(args.agentReport, null, 2)}\n`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function runAuditSafe(args: {
|
|
436
|
+
argv: string[];
|
|
437
|
+
homeDir?: string;
|
|
438
|
+
}): Promise<{
|
|
439
|
+
added: number;
|
|
440
|
+
matched: number;
|
|
441
|
+
source: AuditSafeSource;
|
|
442
|
+
totalSuppressions: number;
|
|
443
|
+
}> {
|
|
444
|
+
const parsed = parseAuditSafeArgs(args.argv);
|
|
445
|
+
const homeDir = args.homeDir ?? homedir();
|
|
446
|
+
const staticReport = await loadLatestStaticReport(homeDir);
|
|
447
|
+
const agentReport = await loadLatestAgentReport(homeDir);
|
|
448
|
+
|
|
449
|
+
if (!(staticReport || agentReport)) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
"No latest audit reports found. Run `fclt audit` first, then mark findings safe."
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const source = inferSource({
|
|
456
|
+
requested: parsed.source,
|
|
457
|
+
staticReport,
|
|
458
|
+
agentReport,
|
|
459
|
+
});
|
|
460
|
+
const reportResults =
|
|
461
|
+
source === "static"
|
|
462
|
+
? (staticReport?.results ?? [])
|
|
463
|
+
: source === "agent"
|
|
464
|
+
? (agentReport?.results ?? [])
|
|
465
|
+
: mergeStaticAndAgentResults({
|
|
466
|
+
static: staticReport?.results ?? [],
|
|
467
|
+
agent: agentReport?.results ?? [],
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const selections = reportResults.flatMap((result) =>
|
|
471
|
+
result.findings
|
|
472
|
+
.filter((finding) =>
|
|
473
|
+
parsed.all
|
|
474
|
+
? true
|
|
475
|
+
: matchesFinding({
|
|
476
|
+
result,
|
|
477
|
+
finding,
|
|
478
|
+
filters: parsed,
|
|
479
|
+
})
|
|
480
|
+
)
|
|
481
|
+
.map((finding) => ({ result, finding }))
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const uniqueSelections = uniqueByKey(
|
|
485
|
+
selections,
|
|
486
|
+
({ result, finding }) =>
|
|
487
|
+
`${result.type}\0${result.item}\0${result.path}\0${finding.severity}\0${normalizeRuleId(finding.ruleId)}\0${finding.message}\0${finding.location ?? ""}`
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
if (uniqueSelections.length === 0) {
|
|
491
|
+
throw new Error("No findings matched the requested filters.");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (parsed.dryRun) {
|
|
495
|
+
const totalSuppressions = (await loadAuditSuppressions(homeDir)).length;
|
|
496
|
+
return {
|
|
497
|
+
added: 0,
|
|
498
|
+
matched: uniqueSelections.length,
|
|
499
|
+
source,
|
|
500
|
+
totalSuppressions,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const saved = await recordAuditSuppressions({
|
|
505
|
+
homeDir,
|
|
506
|
+
selected: uniqueSelections,
|
|
507
|
+
note: parsed.note,
|
|
508
|
+
});
|
|
509
|
+
const suppressions = await loadAuditSuppressions(homeDir);
|
|
510
|
+
const nextStaticReport = staticReport
|
|
511
|
+
? applyAuditSuppressionsToStaticReport(staticReport, suppressions)
|
|
512
|
+
: null;
|
|
513
|
+
const nextAgentReport = agentReport
|
|
514
|
+
? applyAuditSuppressionsToAgentReport(agentReport, suppressions)
|
|
515
|
+
: null;
|
|
516
|
+
|
|
517
|
+
await rewriteLatestReports({
|
|
518
|
+
homeDir,
|
|
519
|
+
staticReport: nextStaticReport,
|
|
520
|
+
agentReport: nextAgentReport,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await updateIndexFromAuditReport({
|
|
524
|
+
homeDir,
|
|
525
|
+
timestamp: new Date().toISOString(),
|
|
526
|
+
results: uniqueByKey(
|
|
527
|
+
mergeStaticAndAgentResults({
|
|
528
|
+
static: nextStaticReport?.results ?? [],
|
|
529
|
+
agent: nextAgentReport?.results ?? [],
|
|
530
|
+
}),
|
|
531
|
+
keyForResult
|
|
532
|
+
),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
added: saved.added,
|
|
537
|
+
matched: uniqueSelections.length,
|
|
538
|
+
source,
|
|
539
|
+
totalSuppressions: saved.total,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function printHelp() {
|
|
544
|
+
console.log(`fclt audit safe — suppress reviewed findings for future audits
|
|
545
|
+
|
|
546
|
+
Usage:
|
|
547
|
+
fclt audit safe <item> [--rule <id>] [--location <text>] [--message <text>]
|
|
548
|
+
fclt audit safe --item <item> [--path <path>] [--severity <level>] [--note <text>]
|
|
549
|
+
fclt audit safe --all --source <static|agent|combined> [--note <text>] [--yes]
|
|
550
|
+
fclt audit safe --dry-run ...
|
|
551
|
+
|
|
552
|
+
Notes:
|
|
553
|
+
- Reads the latest saved audit reports from ~/.ai/.facult/audit/.
|
|
554
|
+
- Matching is non-interactive and agent-safe.
|
|
555
|
+
- Combined review suppressions also match future raw static/agent findings.
|
|
556
|
+
`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export async function auditSafeCommand(
|
|
560
|
+
argv: string[],
|
|
561
|
+
opts?: { homeDir?: string }
|
|
562
|
+
) {
|
|
563
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
564
|
+
printHelp();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
const result = await runAuditSafe({
|
|
570
|
+
argv,
|
|
571
|
+
homeDir: opts?.homeDir,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (argv.includes("--json")) {
|
|
575
|
+
console.log(JSON.stringify(result, null, 2));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (argv.includes("--dry-run")) {
|
|
580
|
+
console.log(
|
|
581
|
+
`Matched ${result.matched} finding${result.matched === 1 ? "" : "s"} in the ${result.source} audit view.`
|
|
582
|
+
);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
console.log(
|
|
587
|
+
`Marked ${result.matched} finding${result.matched === 1 ? "" : "s"} safe in the ${result.source} audit view.`
|
|
588
|
+
);
|
|
589
|
+
console.log(
|
|
590
|
+
`Saved ${result.added} new suppression${result.added === 1 ? "" : "s"} (${result.totalSuppressions} total).`
|
|
591
|
+
);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
594
|
+
process.exitCode = 1;
|
|
595
|
+
}
|
|
596
|
+
}
|