@mafdet/cli 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/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README.zh-CN.md +148 -0
- package/dist/agent/approvals.d.ts +38 -0
- package/dist/agent/approvals.d.ts.map +1 -0
- package/dist/agent/approvals.js +53 -0
- package/dist/agent/command-policy.d.ts +36 -0
- package/dist/agent/command-policy.d.ts.map +1 -0
- package/dist/agent/command-policy.js +133 -0
- package/dist/agent/loop.d.ts +107 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +242 -0
- package/dist/agent/session.d.ts +62 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +290 -0
- package/dist/agent/tools/read-only.d.ts +15 -0
- package/dist/agent/tools/read-only.d.ts.map +1 -0
- package/dist/agent/tools/read-only.js +254 -0
- package/dist/agent/tools/run-command.d.ts +20 -0
- package/dist/agent/tools/run-command.d.ts.map +1 -0
- package/dist/agent/tools/run-command.js +116 -0
- package/dist/agent/tools/types.d.ts +46 -0
- package/dist/agent/tools/types.d.ts.map +1 -0
- package/dist/agent/tools/types.js +16 -0
- package/dist/agent/tools/write.d.ts +3 -0
- package/dist/agent/tools/write.d.ts.map +1 -0
- package/dist/agent/tools/write.js +68 -0
- package/dist/api/account.d.ts +39 -0
- package/dist/api/account.d.ts.map +1 -0
- package/dist/api/account.js +39 -0
- package/dist/api/errors.d.ts +38 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +149 -0
- package/dist/api/http.d.ts +63 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +207 -0
- package/dist/api/models.d.ts +68 -0
- package/dist/api/models.d.ts.map +1 -0
- package/dist/api/models.js +91 -0
- package/dist/api/sse-parser.d.ts +72 -0
- package/dist/api/sse-parser.d.ts.map +1 -0
- package/dist/api/sse-parser.js +163 -0
- package/dist/commands/account.d.ts +4 -0
- package/dist/commands/account.d.ts.map +1 -0
- package/dist/commands/account.js +103 -0
- package/dist/commands/agent.d.ts +10 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +268 -0
- package/dist/commands/chat.d.ts +13 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +191 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +74 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +157 -0
- package/dist/commands/embed.d.ts +7 -0
- package/dist/commands/embed.d.ts.map +1 -0
- package/dist/commands/embed.js +105 -0
- package/dist/commands/models.d.ts +4 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/commands/models.js +151 -0
- package/dist/commands/runtime.d.ts +29 -0
- package/dist/commands/runtime.d.ts.map +1 -0
- package/dist/commands/runtime.js +56 -0
- package/dist/commands/speech.d.ts +7 -0
- package/dist/commands/speech.d.ts.map +1 -0
- package/dist/commands/speech.js +111 -0
- package/dist/config/config.d.ts +57 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +129 -0
- package/dist/config/load.d.ts +20 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +48 -0
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +26 -0
- package/dist/filesystem/diff.d.ts +12 -0
- package/dist/filesystem/diff.d.ts.map +1 -0
- package/dist/filesystem/diff.js +109 -0
- package/dist/filesystem/sensitive-files.d.ts +19 -0
- package/dist/filesystem/sensitive-files.d.ts.map +1 -0
- package/dist/filesystem/sensitive-files.js +46 -0
- package/dist/filesystem/workspace.d.ts +92 -0
- package/dist/filesystem/workspace.d.ts.map +1 -0
- package/dist/filesystem/workspace.js +264 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/output/exit-codes.d.ts +25 -0
- package/dist/output/exit-codes.d.ts.map +1 -0
- package/dist/output/exit-codes.js +23 -0
- package/dist/output/format.d.ts +28 -0
- package/dist/output/format.d.ts.map +1 -0
- package/dist/output/format.js +56 -0
- package/dist/output/money.d.ts +16 -0
- package/dist/output/money.d.ts.map +1 -0
- package/dist/output/money.js +35 -0
- package/dist/output/write-file.d.ts +11 -0
- package/dist/output/write-file.d.ts.map +1 -0
- package/dist/output/write-file.js +38 -0
- package/dist/security/redact.d.ts +16 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +61 -0
- package/package.json +51 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { ApiError } from "../api/errors.js";
|
|
3
|
+
import { assertSurface, fetchCatalog, SurfaceMismatchError, } from "../api/models.js";
|
|
4
|
+
import { ExitCode } from "../output/exit-codes.js";
|
|
5
|
+
import { writeOutputFile } from "../output/write-file.js";
|
|
6
|
+
import { buildRuntime, reportError, requireApiKey, } from "./runtime.js";
|
|
7
|
+
const USAGE = `Usage:
|
|
8
|
+
mafdet speech [text | <file>] --model <id> --output <file> [--voice <name>] [--force]
|
|
9
|
+
|
|
10
|
+
Reads the text from an argument, a file, or stdin. --output is required:
|
|
11
|
+
audio is binary and must never be written to a terminal.
|
|
12
|
+
`;
|
|
13
|
+
export async function speechCommand(argv, io) {
|
|
14
|
+
try {
|
|
15
|
+
const runtime = buildRuntime(io);
|
|
16
|
+
requireApiKey(runtime.config);
|
|
17
|
+
let model = runtime.config.model;
|
|
18
|
+
let output;
|
|
19
|
+
let voice;
|
|
20
|
+
let force = false;
|
|
21
|
+
const positional = [];
|
|
22
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
23
|
+
const arg = argv[i];
|
|
24
|
+
const next = () => argv[(i += 1)];
|
|
25
|
+
if (arg === "--model")
|
|
26
|
+
model = next();
|
|
27
|
+
else if (arg === "--output" || arg === "-o")
|
|
28
|
+
output = next();
|
|
29
|
+
else if (arg === "--voice")
|
|
30
|
+
voice = next();
|
|
31
|
+
else if (arg === "--force")
|
|
32
|
+
force = true;
|
|
33
|
+
else if (arg.startsWith("--")) {
|
|
34
|
+
io.stderr(`mafdet speech: unknown option '${arg}'\n${USAGE}`);
|
|
35
|
+
return ExitCode.Usage;
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
positional.push(arg);
|
|
39
|
+
}
|
|
40
|
+
if (!model) {
|
|
41
|
+
io.stderr("mafdet speech: no model. Pass --model or set MAFDET_MODEL.\n");
|
|
42
|
+
return ExitCode.Usage;
|
|
43
|
+
}
|
|
44
|
+
if (!output) {
|
|
45
|
+
// Binary on a terminal corrupts the session and loses the audio.
|
|
46
|
+
io.stderr(`mafdet speech: --output is required\n${USAGE}`);
|
|
47
|
+
return ExitCode.Usage;
|
|
48
|
+
}
|
|
49
|
+
const input = await resolveInput(positional, io);
|
|
50
|
+
if (input === undefined)
|
|
51
|
+
return ExitCode.Usage;
|
|
52
|
+
const catalog = await fetchCatalog(runtime.accountPlane).catch(() => []);
|
|
53
|
+
assertSurface(catalog, model, "AUDIO_SPEECH");
|
|
54
|
+
const body = { model, input };
|
|
55
|
+
if (voice)
|
|
56
|
+
body.voice = voice;
|
|
57
|
+
const response = await runtime.dataPlane.postBinary("/audio/speech", body);
|
|
58
|
+
if (response.data.byteLength === 0) {
|
|
59
|
+
throw new ApiError({
|
|
60
|
+
kind: "server",
|
|
61
|
+
exitCode: ExitCode.Unavailable,
|
|
62
|
+
requestId: response.requestId,
|
|
63
|
+
message: "the API returned no audio",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
writeOutputFile(output, response.data, { force });
|
|
67
|
+
io.stderr(`wrote ${output} (${response.data.byteLength} bytes, ${response.headers.get("content-type") ?? "unknown type"})\n`);
|
|
68
|
+
return ExitCode.Ok;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof SurfaceMismatchError) {
|
|
72
|
+
io.stderr(`mafdet speech: ${error.message}\n`);
|
|
73
|
+
return ExitCode.Usage;
|
|
74
|
+
}
|
|
75
|
+
// The gateway requires a voice for TTS and says so with a code; turn that
|
|
76
|
+
// into the flag the user needs to add.
|
|
77
|
+
if (error instanceof ApiError && error.code === "TTS_VOICE_REQUIRED") {
|
|
78
|
+
io.stderr("mafdet speech: this model needs a voice - pass --voice <name> (for example --voice Kore)\n");
|
|
79
|
+
return ExitCode.Usage;
|
|
80
|
+
}
|
|
81
|
+
return reportError(error, io);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function resolveInput(positional, io) {
|
|
85
|
+
if (positional.length > 1) {
|
|
86
|
+
io.stderr(`mafdet speech: expected one input, got ${positional.length}\n${USAGE}`);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
if (positional.length === 1) {
|
|
90
|
+
const value = positional[0];
|
|
91
|
+
try {
|
|
92
|
+
return readFileSync(value, "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === "ENOENT")
|
|
96
|
+
return value;
|
|
97
|
+
throw new ApiError({
|
|
98
|
+
kind: "request",
|
|
99
|
+
exitCode: ExitCode.Usage,
|
|
100
|
+
message: `cannot read ${value}: ${error.message}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (io.readStdin && !process.stdin.isTTY) {
|
|
105
|
+
const piped = (await io.readStdin()).trim();
|
|
106
|
+
if (piped.length > 0)
|
|
107
|
+
return piped;
|
|
108
|
+
}
|
|
109
|
+
io.stderr(`mafdet speech: no input\n${USAGE}`);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const DEFAULT_BASE_URL = "https://api.mafdet.ai/v1";
|
|
3
|
+
export declare const DEFAULT_BACKEND_URL = "https://backend.mafdet.ai";
|
|
4
|
+
export declare class ConfigError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
declare const projectConfigSchema: z.ZodObject<{
|
|
8
|
+
model: z.ZodOptional<z.ZodString>;
|
|
9
|
+
approvalMode: z.ZodOptional<z.ZodEnum<{
|
|
10
|
+
"read-only": "read-only";
|
|
11
|
+
suggest: "suggest";
|
|
12
|
+
"auto-edit": "auto-edit";
|
|
13
|
+
}>>;
|
|
14
|
+
testCommands: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
15
|
+
}, z.core.$strict>;
|
|
16
|
+
declare const userConfigSchema: z.ZodObject<{
|
|
17
|
+
model: z.ZodOptional<z.ZodString>;
|
|
18
|
+
approvalMode: z.ZodOptional<z.ZodEnum<{
|
|
19
|
+
"read-only": "read-only";
|
|
20
|
+
suggest: "suggest";
|
|
21
|
+
"auto-edit": "auto-edit";
|
|
22
|
+
}>>;
|
|
23
|
+
baseUrl: z.ZodOptional<z.ZodString>;
|
|
24
|
+
backendUrl: z.ZodOptional<z.ZodString>;
|
|
25
|
+
}, z.core.$strict>;
|
|
26
|
+
export type ProjectConfig = z.infer<typeof projectConfigSchema>;
|
|
27
|
+
export type UserConfig = z.infer<typeof userConfigSchema>;
|
|
28
|
+
/**
|
|
29
|
+
* Remote endpoints must be HTTPS. Plain HTTP is allowed only against the local
|
|
30
|
+
* machine, where there is no network to intercept — that exception exists so a
|
|
31
|
+
* developer can point the CLI at a local proxy without a certificate.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assertTransportAllowed(rawUrl: string, field: string): URL;
|
|
34
|
+
export declare function parseUserConfig(raw: unknown, source: string): UserConfig;
|
|
35
|
+
export declare function parseProjectConfig(raw: unknown, source: string): ProjectConfig;
|
|
36
|
+
export interface ResolveInput {
|
|
37
|
+
/** Parsed `--model`, highest precedence. */
|
|
38
|
+
flags?: {
|
|
39
|
+
model?: string;
|
|
40
|
+
};
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
project?: ProjectConfig;
|
|
43
|
+
user?: UserConfig;
|
|
44
|
+
}
|
|
45
|
+
export interface ResolvedConfig {
|
|
46
|
+
model?: string;
|
|
47
|
+
/** Where `model` came from — `mafdet config list` shows this. */
|
|
48
|
+
modelSource: "flag" | "env" | "project" | "user" | "none";
|
|
49
|
+
baseUrl: string;
|
|
50
|
+
backendUrl: string;
|
|
51
|
+
approvalMode: "read-only" | "suggest" | "auto-edit";
|
|
52
|
+
apiKey?: string;
|
|
53
|
+
}
|
|
54
|
+
/** Plan §5.1: flag > env > project > user > default. */
|
|
55
|
+
export declare function resolveConfig(input?: ResolveInput): ResolvedConfig;
|
|
56
|
+
export {};
|
|
57
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,gBAAgB,6BAA6B,CAAC;AAC3D,eAAO,MAAM,mBAAmB,8BAA8B,CAAC;AAE/D,qBAAa,WAAY,SAAQ,KAAK;gBACxB,OAAO,EAAE,MAAM;CAK5B;AAYD,QAAA,MAAM,mBAAmB;;;;;;;;kBAMd,CAAC;AAEZ,QAAA,MAAM,gBAAgB;;;;;;;;;kBAOX,CAAC;AAEZ,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAChE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,GAAG,CAczE;AAOD,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,CAWxE;AAED,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,GACb,aAAa,CAcf;AAED,MAAM,WAAW,YAAY;IAC3B,4CAA4C;IAC5C,KAAK,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,WAAW,EAAE,MAAM,GAAG,KAAK,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,WAAW,GAAG,SAAS,GAAG,WAAW,CAAC;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wDAAwD;AACxD,wBAAgB,aAAa,CAAC,KAAK,GAAE,YAAiB,GAAG,cAAc,CAiCtE"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../security/redact.js";
|
|
3
|
+
export const DEFAULT_BASE_URL = "https://api.mafdet.ai/v1";
|
|
4
|
+
export const DEFAULT_BACKEND_URL = "https://backend.mafdet.ai";
|
|
5
|
+
export class ConfigError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
// The message may quote a config value; redact before it can be printed.
|
|
8
|
+
super(redact(message));
|
|
9
|
+
this.name = "ConfigError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Settings a project may carry. A repository is untrusted input: cloning it
|
|
14
|
+
* must never change where the CLI sends the key, so connection settings and
|
|
15
|
+
* credentials are not merely ignored here — they are rejected loudly, because
|
|
16
|
+
* silently dropping them would hide an attempt to redirect traffic.
|
|
17
|
+
*/
|
|
18
|
+
const FORBIDDEN_IN_PROJECT = ["apiKey", "baseUrl", "backendUrl"];
|
|
19
|
+
const approvalMode = z.enum(["read-only", "suggest", "auto-edit"]);
|
|
20
|
+
const projectConfigSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
model: z.string().min(1).optional(),
|
|
23
|
+
approvalMode: approvalMode.optional(),
|
|
24
|
+
testCommands: z.array(z.string().min(1)).optional(),
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
const userConfigSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
model: z.string().min(1).optional(),
|
|
30
|
+
approvalMode: approvalMode.optional(),
|
|
31
|
+
baseUrl: z.string().url().optional(),
|
|
32
|
+
backendUrl: z.string().url().optional(),
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
35
|
+
/**
|
|
36
|
+
* Remote endpoints must be HTTPS. Plain HTTP is allowed only against the local
|
|
37
|
+
* machine, where there is no network to intercept — that exception exists so a
|
|
38
|
+
* developer can point the CLI at a local proxy without a certificate.
|
|
39
|
+
*/
|
|
40
|
+
export function assertTransportAllowed(rawUrl, field) {
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(rawUrl);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new ConfigError(`${field} is not a valid URL: ${rawUrl}`);
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol === "https:")
|
|
49
|
+
return url;
|
|
50
|
+
if (url.protocol === "http:" && isLoopback(url.hostname))
|
|
51
|
+
return url;
|
|
52
|
+
throw new ConfigError(`${field} must use https (plain http is allowed only for localhost): ${rawUrl}`);
|
|
53
|
+
}
|
|
54
|
+
function isLoopback(hostname) {
|
|
55
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
56
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
57
|
+
}
|
|
58
|
+
export function parseUserConfig(raw, source) {
|
|
59
|
+
if (hasKey(raw, "apiKey")) {
|
|
60
|
+
throw new ConfigError(`${source} contains an apiKey. The CLI never reads a key from a file - remove it and set MAFDET_API_KEY instead.`);
|
|
61
|
+
}
|
|
62
|
+
const parsed = userConfigSchema.safeParse(raw);
|
|
63
|
+
if (!parsed.success) {
|
|
64
|
+
throw new ConfigError(`${source} is invalid: ${issueText(parsed.error)}`);
|
|
65
|
+
}
|
|
66
|
+
return parsed.data;
|
|
67
|
+
}
|
|
68
|
+
export function parseProjectConfig(raw, source) {
|
|
69
|
+
for (const forbidden of FORBIDDEN_IN_PROJECT) {
|
|
70
|
+
if (hasKey(raw, forbidden)) {
|
|
71
|
+
throw new ConfigError(`${source} sets '${forbidden}', which a project may not control. ` +
|
|
72
|
+
`Credentials and endpoints come from the environment or your user config only.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const parsed = projectConfigSchema.safeParse(raw);
|
|
76
|
+
if (!parsed.success) {
|
|
77
|
+
throw new ConfigError(`${source} is invalid: ${issueText(parsed.error)}`);
|
|
78
|
+
}
|
|
79
|
+
return parsed.data;
|
|
80
|
+
}
|
|
81
|
+
/** Plan §5.1: flag > env > project > user > default. */
|
|
82
|
+
export function resolveConfig(input = {}) {
|
|
83
|
+
const env = input.env ?? process.env;
|
|
84
|
+
const [model, modelSource] = firstOf([
|
|
85
|
+
["flag", input.flags?.model],
|
|
86
|
+
["env", trimmed(env.MAFDET_MODEL)],
|
|
87
|
+
["project", input.project?.model],
|
|
88
|
+
["user", input.user?.model],
|
|
89
|
+
]);
|
|
90
|
+
// Endpoints deliberately skip the project layer.
|
|
91
|
+
const baseUrl = trimmed(env.MAFDET_BASE_URL) ?? input.user?.baseUrl ?? DEFAULT_BASE_URL;
|
|
92
|
+
const backendUrl = trimmed(env.MAFDET_BACKEND_URL) ??
|
|
93
|
+
input.user?.backendUrl ??
|
|
94
|
+
DEFAULT_BACKEND_URL;
|
|
95
|
+
assertTransportAllowed(baseUrl, "base URL");
|
|
96
|
+
assertTransportAllowed(backendUrl, "backend URL");
|
|
97
|
+
return {
|
|
98
|
+
model,
|
|
99
|
+
modelSource,
|
|
100
|
+
baseUrl,
|
|
101
|
+
backendUrl,
|
|
102
|
+
// read-only is the safe default; a caller raises it explicitly.
|
|
103
|
+
approvalMode: input.project?.approvalMode ?? input.user?.approvalMode ?? "read-only",
|
|
104
|
+
// The key is environment-only, by design (plan §5.4): a flag would put it
|
|
105
|
+
// in shell history and the process list, a file would outlive its use.
|
|
106
|
+
apiKey: trimmed(env.MAFDET_API_KEY),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function firstOf(candidates) {
|
|
110
|
+
for (const [source, value] of candidates) {
|
|
111
|
+
if (value !== undefined && value.length > 0)
|
|
112
|
+
return [value, source];
|
|
113
|
+
}
|
|
114
|
+
return [undefined, "none"];
|
|
115
|
+
}
|
|
116
|
+
function trimmed(value) {
|
|
117
|
+
const text = value?.trim();
|
|
118
|
+
return text && text.length > 0 ? text : undefined;
|
|
119
|
+
}
|
|
120
|
+
function hasKey(raw, key) {
|
|
121
|
+
return (typeof raw === "object" &&
|
|
122
|
+
raw !== null &&
|
|
123
|
+
Object.prototype.hasOwnProperty.call(raw, key));
|
|
124
|
+
}
|
|
125
|
+
function issueText(error) {
|
|
126
|
+
return error.issues
|
|
127
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
128
|
+
.join("; ");
|
|
129
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ResolvedConfig } from "./config.js";
|
|
2
|
+
export interface LoadOptions {
|
|
3
|
+
cwd?: string;
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
flags?: {
|
|
6
|
+
model?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Overrides for tests; production resolves from the platform. */
|
|
9
|
+
userConfigFile?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface LoadedConfig extends ResolvedConfig {
|
|
12
|
+
sources: {
|
|
13
|
+
userConfigPath: string;
|
|
14
|
+
projectConfigPath: string;
|
|
15
|
+
userConfigFound: boolean;
|
|
16
|
+
projectConfigFound: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare function loadConfig(options?: LoadOptions): LoadedConfig;
|
|
20
|
+
//# sourceMappingURL=load.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../../src/config/load.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,cAAc,EAKpB,MAAM,aAAa,CAAC;AAGrB,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,KAAK,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3B,kEAAkE;IAClE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD,OAAO,EAAE;QACP,cAAc,EAAE,MAAM,CAAC;QACvB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,eAAe,EAAE,OAAO,CAAC;QACzB,kBAAkB,EAAE,OAAO,CAAC;KAC7B,CAAC;CACH;AAwBD,wBAAgB,UAAU,CAAC,OAAO,GAAE,WAAgB,GAAG,YAAY,CAyBlE"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ConfigError, parseProjectConfig, parseUserConfig, resolveConfig, } from "./config.js";
|
|
4
|
+
import { PROJECT_CONFIG_FILE, userConfigPath } from "./paths.js";
|
|
5
|
+
/** Read a JSON config if it exists. A missing file is normal; a broken one is not. */
|
|
6
|
+
function readJsonIfPresent(path) {
|
|
7
|
+
let text;
|
|
8
|
+
try {
|
|
9
|
+
text = readFileSync(path, "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code === "ENOENT") {
|
|
13
|
+
return { found: false, value: undefined };
|
|
14
|
+
}
|
|
15
|
+
// Unreadable-but-present (permissions, a directory) must not be mistaken
|
|
16
|
+
// for absent: silently falling back would hide the user's real settings.
|
|
17
|
+
throw new ConfigError(`cannot read ${path}: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return { found: true, value: JSON.parse(text) };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new ConfigError(`${path} is not valid JSON: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function loadConfig(options = {}) {
|
|
27
|
+
const env = options.env ?? process.env;
|
|
28
|
+
const cwd = options.cwd ?? process.cwd();
|
|
29
|
+
const userPath = options.userConfigFile ?? userConfigPath(env);
|
|
30
|
+
const projectPath = join(cwd, PROJECT_CONFIG_FILE);
|
|
31
|
+
const userRaw = readJsonIfPresent(userPath);
|
|
32
|
+
const projectRaw = readJsonIfPresent(projectPath);
|
|
33
|
+
const user = userRaw.found
|
|
34
|
+
? parseUserConfig(userRaw.value, userPath)
|
|
35
|
+
: undefined;
|
|
36
|
+
const project = projectRaw.found
|
|
37
|
+
? parseProjectConfig(projectRaw.value, projectPath)
|
|
38
|
+
: undefined;
|
|
39
|
+
return {
|
|
40
|
+
...resolveConfig({ env, flags: options.flags, project, user }),
|
|
41
|
+
sources: {
|
|
42
|
+
userConfigPath: userPath,
|
|
43
|
+
projectConfigPath: projectPath,
|
|
44
|
+
userConfigFound: userRaw.found,
|
|
45
|
+
projectConfigFound: projectRaw.found,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const CONFIG_FILE = "config.json";
|
|
2
|
+
/** Project-level config, read from the working directory. */
|
|
3
|
+
export declare const PROJECT_CONFIG_FILE = ".mafdet.json";
|
|
4
|
+
/**
|
|
5
|
+
* Per-platform user config directory (plan §5.3). XDG_CONFIG_HOME and APPDATA
|
|
6
|
+
* are honoured where the platform defines them, so a machine that relocates
|
|
7
|
+
* its config root keeps working.
|
|
8
|
+
*/
|
|
9
|
+
export declare function userConfigDir(env?: NodeJS.ProcessEnv, os?: string, home?: string): string;
|
|
10
|
+
export declare function userConfigPath(env?: NodeJS.ProcessEnv, os?: string, home?: string): string;
|
|
11
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/config/paths.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,WAAW,gBAAgB,CAAC;AACzC,6DAA6D;AAC7D,eAAO,MAAM,mBAAmB,iBAAiB,CAAC;AAElD;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,EAAE,GAAE,MAAmB,EACvB,IAAI,GAAE,MAAkB,GACvB,MAAM,CAeR;AAED,wBAAgB,cAAc,CAC5B,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,EAAE,GAAE,MAAmB,EACvB,IAAI,GAAE,MAAkB,GACvB,MAAM,CAER"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { homedir, platform } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const CONFIG_FILE = "config.json";
|
|
4
|
+
/** Project-level config, read from the working directory. */
|
|
5
|
+
export const PROJECT_CONFIG_FILE = ".mafdet.json";
|
|
6
|
+
/**
|
|
7
|
+
* Per-platform user config directory (plan §5.3). XDG_CONFIG_HOME and APPDATA
|
|
8
|
+
* are honoured where the platform defines them, so a machine that relocates
|
|
9
|
+
* its config root keeps working.
|
|
10
|
+
*/
|
|
11
|
+
export function userConfigDir(env = process.env, os = platform(), home = homedir()) {
|
|
12
|
+
if (os === "win32") {
|
|
13
|
+
const appData = env.APPDATA?.trim();
|
|
14
|
+
return join(appData && appData.length > 0
|
|
15
|
+
? appData
|
|
16
|
+
: join(home, "AppData", "Roaming"), "mafdet");
|
|
17
|
+
}
|
|
18
|
+
if (os === "darwin") {
|
|
19
|
+
return join(home, "Library", "Application Support", "mafdet");
|
|
20
|
+
}
|
|
21
|
+
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
22
|
+
return join(xdg && xdg.length > 0 ? xdg : join(home, ".config"), "mafdet");
|
|
23
|
+
}
|
|
24
|
+
export function userConfigPath(env = process.env, os = platform(), home = homedir()) {
|
|
25
|
+
return join(userConfigDir(env, os, home), CONFIG_FILE);
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A unified diff, generated locally so the user sees exactly what would change
|
|
3
|
+
* before approving it. No dependency: the input is two versions of one file,
|
|
4
|
+
* and an LCS over lines is enough.
|
|
5
|
+
*/
|
|
6
|
+
export interface DiffOptions {
|
|
7
|
+
/** Lines of unchanged context around each change. */
|
|
8
|
+
context?: number;
|
|
9
|
+
path?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function unifiedDiff(before: string, after: string, options?: DiffOptions): string;
|
|
12
|
+
//# sourceMappingURL=diff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/filesystem/diff.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,WAAW;IAC1B,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,WAAgB,GACxB,MAAM,CAuER"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A unified diff, generated locally so the user sees exactly what would change
|
|
3
|
+
* before approving it. No dependency: the input is two versions of one file,
|
|
4
|
+
* and an LCS over lines is enough.
|
|
5
|
+
*/
|
|
6
|
+
export function unifiedDiff(before, after, options = {}) {
|
|
7
|
+
const context = options.context ?? 3;
|
|
8
|
+
const a = before.split("\n");
|
|
9
|
+
const b = after.split("\n");
|
|
10
|
+
const ops = diffLines(a, b);
|
|
11
|
+
const hunks = [];
|
|
12
|
+
let index = 0;
|
|
13
|
+
while (index < ops.length) {
|
|
14
|
+
if (ops[index].kind === "same") {
|
|
15
|
+
index += 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
// Grow a hunk over nearby changes so a cluster reads as one block.
|
|
19
|
+
let start = index;
|
|
20
|
+
let end = index;
|
|
21
|
+
for (let i = index; i < ops.length; i += 1) {
|
|
22
|
+
if (ops[i].kind !== "same") {
|
|
23
|
+
end = i;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
let run = 0;
|
|
27
|
+
let j = i;
|
|
28
|
+
while (j < ops.length && ops[j].kind === "same") {
|
|
29
|
+
run += 1;
|
|
30
|
+
j += 1;
|
|
31
|
+
}
|
|
32
|
+
if (run > context * 2 || j >= ops.length)
|
|
33
|
+
break;
|
|
34
|
+
i = j - 1;
|
|
35
|
+
}
|
|
36
|
+
start = Math.max(0, start - context);
|
|
37
|
+
end = Math.min(ops.length - 1, end + context);
|
|
38
|
+
let aLine = 1;
|
|
39
|
+
let bLine = 1;
|
|
40
|
+
for (let i = 0; i < start; i += 1) {
|
|
41
|
+
if (ops[i].kind !== "add")
|
|
42
|
+
aLine += 1;
|
|
43
|
+
if (ops[i].kind !== "remove")
|
|
44
|
+
bLine += 1;
|
|
45
|
+
}
|
|
46
|
+
const body = [];
|
|
47
|
+
let aCount = 0;
|
|
48
|
+
let bCount = 0;
|
|
49
|
+
for (let i = start; i <= end; i += 1) {
|
|
50
|
+
const op = ops[i];
|
|
51
|
+
if (op.kind === "same") {
|
|
52
|
+
body.push(` ${op.line}`);
|
|
53
|
+
aCount += 1;
|
|
54
|
+
bCount += 1;
|
|
55
|
+
}
|
|
56
|
+
else if (op.kind === "remove") {
|
|
57
|
+
body.push(`-${op.line}`);
|
|
58
|
+
aCount += 1;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
body.push(`+${op.line}`);
|
|
62
|
+
bCount += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
hunks.push(`@@ -${aLine},${aCount} +${bLine},${bCount} @@\n${body.join("\n")}`);
|
|
66
|
+
index = end + 1;
|
|
67
|
+
}
|
|
68
|
+
if (hunks.length === 0)
|
|
69
|
+
return "";
|
|
70
|
+
const header = options.path
|
|
71
|
+
? `--- a/${options.path}\n+++ b/${options.path}\n`
|
|
72
|
+
: "";
|
|
73
|
+
return `${header}${hunks.join("\n")}\n`;
|
|
74
|
+
}
|
|
75
|
+
/** Classic LCS table. Files here are bounded by MAX_FILE_BYTES, so this is fine. */
|
|
76
|
+
function diffLines(a, b) {
|
|
77
|
+
const lengths = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
78
|
+
for (let i = a.length - 1; i >= 0; i -= 1) {
|
|
79
|
+
for (let j = b.length - 1; j >= 0; j -= 1) {
|
|
80
|
+
lengths[i][j] =
|
|
81
|
+
a[i] === b[j]
|
|
82
|
+
? lengths[i + 1][j + 1] + 1
|
|
83
|
+
: Math.max(lengths[i + 1][j], lengths[i][j + 1]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const ops = [];
|
|
87
|
+
let i = 0;
|
|
88
|
+
let j = 0;
|
|
89
|
+
while (i < a.length && j < b.length) {
|
|
90
|
+
if (a[i] === b[j]) {
|
|
91
|
+
ops.push({ kind: "same", line: a[i] });
|
|
92
|
+
i += 1;
|
|
93
|
+
j += 1;
|
|
94
|
+
}
|
|
95
|
+
else if (lengths[i + 1][j] >= lengths[i][j + 1]) {
|
|
96
|
+
ops.push({ kind: "remove", line: a[i] });
|
|
97
|
+
i += 1;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
ops.push({ kind: "add", line: b[j] });
|
|
101
|
+
j += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
while (i < a.length)
|
|
105
|
+
ops.push({ kind: "remove", line: a[i++] });
|
|
106
|
+
while (j < b.length)
|
|
107
|
+
ops.push({ kind: "add", line: b[j++] });
|
|
108
|
+
return ops;
|
|
109
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the agent may not touch, however the model asks.
|
|
3
|
+
*
|
|
4
|
+
* These are denied by path, before anything is opened, and there is no broad
|
|
5
|
+
* escape hatch: plan §8.3 rules out an `--allow-secrets` flag precisely
|
|
6
|
+
* because one flag would re-open all of it. A user who genuinely needs one of
|
|
7
|
+
* these files authorises that exact path.
|
|
8
|
+
*/
|
|
9
|
+
export interface SensitiveVerdict {
|
|
10
|
+
denied: boolean;
|
|
11
|
+
/** Which rule matched, for an explanation the user can act on. */
|
|
12
|
+
reason?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* @param relativePath workspace-relative, POSIX-separated, already validated
|
|
16
|
+
* as inside the workspace.
|
|
17
|
+
*/
|
|
18
|
+
export declare function classifySensitivePath(relativePath: string): SensitiveVerdict;
|
|
19
|
+
//# sourceMappingURL=sensitive-files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sensitive-files.d.ts","sourceRoot":"","sources":["../../src/filesystem/sensitive-files.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAmBH,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,CAAC;IAChB,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,gBAAgB,CAqB5E"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the agent may not touch, however the model asks.
|
|
3
|
+
*
|
|
4
|
+
* These are denied by path, before anything is opened, and there is no broad
|
|
5
|
+
* escape hatch: plan §8.3 rules out an `--allow-secrets` flag precisely
|
|
6
|
+
* because one flag would re-open all of it. A user who genuinely needs one of
|
|
7
|
+
* these files authorises that exact path.
|
|
8
|
+
*/
|
|
9
|
+
/** Matched against each segment of the workspace-relative path. */
|
|
10
|
+
const DENIED_SEGMENT_PATTERNS = [
|
|
11
|
+
/^\.env($|\.)/i, // .env, .env.local, .env.production
|
|
12
|
+
/\.pem$/i,
|
|
13
|
+
/\.key$/i,
|
|
14
|
+
/^id_rsa/i,
|
|
15
|
+
/^id_ecdsa/i,
|
|
16
|
+
/^id_ed25519/i,
|
|
17
|
+
/^kubeconfig$/i,
|
|
18
|
+
/^credentials/i,
|
|
19
|
+
/^\.npmrc$/i, // carries registry auth tokens
|
|
20
|
+
/^\.pgpass$/i,
|
|
21
|
+
];
|
|
22
|
+
/** Directories whose whole subtree is denied. */
|
|
23
|
+
const DENIED_DIRECTORIES = [".ssh", ".aws", ".gnupg", ".docker", ".kube"];
|
|
24
|
+
/**
|
|
25
|
+
* @param relativePath workspace-relative, POSIX-separated, already validated
|
|
26
|
+
* as inside the workspace.
|
|
27
|
+
*/
|
|
28
|
+
export function classifySensitivePath(relativePath) {
|
|
29
|
+
const segments = relativePath.split("/").filter((part) => part.length > 0);
|
|
30
|
+
for (const directory of DENIED_DIRECTORIES) {
|
|
31
|
+
// Deny the directory itself and everything under it, at any depth: a
|
|
32
|
+
// nested checkout must not expose ~/.aws-style credentials either.
|
|
33
|
+
if (segments.some((segment) => segment.toLowerCase() === directory)) {
|
|
34
|
+
return { denied: true, reason: `${directory}/ holds credentials` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const name = segments.at(-1);
|
|
38
|
+
if (!name)
|
|
39
|
+
return { denied: false };
|
|
40
|
+
for (const pattern of DENIED_SEGMENT_PATTERNS) {
|
|
41
|
+
if (pattern.test(name)) {
|
|
42
|
+
return { denied: true, reason: `${name} looks like a secret` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { denied: false };
|
|
46
|
+
}
|