maqcli 0.5.0 → 0.6.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/dist/core/command-catalog.js +2 -0
- package/dist/core/config-store.d.ts +8 -0
- package/dist/core/config-store.js +28 -1
- package/dist/core/exec.d.ts +9 -0
- package/dist/core/exec.js +7 -2
- package/dist/core/launcher.js +1 -1
- package/dist/core/sandbox.d.ts +14 -1
- package/dist/core/sandbox.js +49 -1
- package/dist/core/security.d.ts +113 -0
- package/dist/core/security.js +303 -0
- package/dist/core/skills.d.ts +9 -0
- package/dist/core/skills.js +10 -2
- package/dist/core/tools.d.ts +2 -0
- package/dist/core/tools.js +7 -1
- package/dist/core/update.d.ts +40 -0
- package/dist/core/update.js +75 -0
- package/dist/index.js +136 -2
- package/dist/phases/scout.js +16 -0
- package/dist/server/daemon.js +9 -1
- package/dist/server/webui.js +67 -0
- package/package.json +1 -1
|
@@ -41,6 +41,8 @@ export const maqCommands = [
|
|
|
41
41
|
{ name: "memory", category: "memory", summary: "Recall memory store (injected into planning).", usage: "maq memory [store|recall|list]", needsInput: "query", args: [] },
|
|
42
42
|
{ name: "flow", category: "control", summary: "Scheduled agent sessions (run under the daemon).", usage: "maq flow [list|add|remove]", needsInput: "none", args: [] },
|
|
43
43
|
{ name: "audit", category: "control", summary: "Verify a run's hash-chained audit log.", usage: "maq audit verify <run-dir>", needsInput: "none", args: [] },
|
|
44
|
+
{ name: "security", category: "system", summary: "Enforced security rules (protected paths, egress allowlist, injection scanning) + recent findings.", usage: "maq security [report|rules|scan <path>]", needsInput: "none", args: [] },
|
|
45
|
+
{ name: "update", category: "system", summary: "Check npm for a newer maqcli and self-update.", usage: "maq update [--check]", needsInput: "none", args: [{ name: "check", type: "boolean", description: "only check, don't install" }] },
|
|
44
46
|
];
|
|
45
47
|
/** Catalog of AI worker CLIs' own commands/flags (verified 2026-07-01). */
|
|
46
48
|
export const aiCliCatalog = [
|
|
@@ -31,6 +31,10 @@ export interface MaqConfig {
|
|
|
31
31
|
headroomModel: string;
|
|
32
32
|
/** True once the guided launcher has completed first-run setup. */
|
|
33
33
|
onboarded: boolean;
|
|
34
|
+
/** Project-specific additions to the protected-path denylist (base list is never removable). */
|
|
35
|
+
extraProtectedPaths: string[];
|
|
36
|
+
/** Additional hosts allowed for network egress (default allowlist is always applied on top). */
|
|
37
|
+
extraNetAllowlist: string[];
|
|
34
38
|
}
|
|
35
39
|
export declare const DEFAULT_CONFIG: MaqConfig;
|
|
36
40
|
export declare function configDir(): string;
|
|
@@ -39,3 +43,7 @@ export declare function loadConfig(): MaqConfig;
|
|
|
39
43
|
export declare function saveConfig(cfg: MaqConfig): void;
|
|
40
44
|
/** Set a single top-level scalar key by string, coercing to the existing type. */
|
|
41
45
|
export declare function setConfigKey(key: string, value: string): MaqConfig;
|
|
46
|
+
/** Append a value to an array-valued config key (e.g. security allowlists). */
|
|
47
|
+
export declare function addConfigListItem(key: string, value: string): MaqConfig;
|
|
48
|
+
/** Remove a value from an array-valued config key. */
|
|
49
|
+
export declare function removeConfigListItem(key: string, value: string): MaqConfig;
|
|
@@ -19,6 +19,8 @@ export const DEFAULT_CONFIG = {
|
|
|
19
19
|
executionMode: "loop",
|
|
20
20
|
headroomModel: "",
|
|
21
21
|
onboarded: false,
|
|
22
|
+
extraProtectedPaths: [],
|
|
23
|
+
extraNetAllowlist: [],
|
|
22
24
|
};
|
|
23
25
|
export function configDir() {
|
|
24
26
|
return process.env.MAQ_CONFIG_DIR ?? join(homedir(), ".maqcli");
|
|
@@ -57,9 +59,34 @@ export function setConfigKey(key, value) {
|
|
|
57
59
|
if (typeof current === "boolean")
|
|
58
60
|
coerced = value === "true";
|
|
59
61
|
if (typeof current === "object") {
|
|
60
|
-
throw new Error(`Cannot set object key '${key}' from the CLI`);
|
|
62
|
+
throw new Error(`Cannot set object key '${key}' from the CLI; use addConfigListItem/removeConfigListItem`);
|
|
61
63
|
}
|
|
62
64
|
cfg[key] = coerced;
|
|
63
65
|
saveConfig(cfg);
|
|
64
66
|
return cfg;
|
|
65
67
|
}
|
|
68
|
+
/** Array-valued config keys that may be extended/trimmed from the CLI. */
|
|
69
|
+
const LIST_KEYS = new Set(["extraProtectedPaths", "extraNetAllowlist"]);
|
|
70
|
+
/** Append a value to an array-valued config key (e.g. security allowlists). */
|
|
71
|
+
export function addConfigListItem(key, value) {
|
|
72
|
+
if (!LIST_KEYS.has(key))
|
|
73
|
+
throw new Error(`'${key}' is not a list config key`);
|
|
74
|
+
const cfg = loadConfig();
|
|
75
|
+
const list = cfg[key];
|
|
76
|
+
if (!list.includes(value))
|
|
77
|
+
list.push(value);
|
|
78
|
+
saveConfig(cfg);
|
|
79
|
+
return cfg;
|
|
80
|
+
}
|
|
81
|
+
/** Remove a value from an array-valued config key. */
|
|
82
|
+
export function removeConfigListItem(key, value) {
|
|
83
|
+
if (!LIST_KEYS.has(key))
|
|
84
|
+
throw new Error(`'${key}' is not a list config key`);
|
|
85
|
+
const cfg = loadConfig();
|
|
86
|
+
const list = cfg[key];
|
|
87
|
+
const idx = list.indexOf(value);
|
|
88
|
+
if (idx >= 0)
|
|
89
|
+
list.splice(idx, 1);
|
|
90
|
+
saveConfig(cfg);
|
|
91
|
+
return cfg;
|
|
92
|
+
}
|
package/dist/core/exec.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ export interface ExecOptions {
|
|
|
14
14
|
maxBuffer?: number;
|
|
15
15
|
/** Abort signal; kills the child when aborted. */
|
|
16
16
|
signal?: AbortSignal;
|
|
17
|
+
/**
|
|
18
|
+
* Secret hygiene for the spawned process (NVIDIA: "secret injection, not
|
|
19
|
+
* inheritance"). Defaults to scrubbing secret-shaped env vars from the
|
|
20
|
+
* inherited environment. Set to false to opt out (e.g. a trusted local
|
|
21
|
+
* tool that legitimately needs a key); pass `secretAllowlist` to re-admit
|
|
22
|
+
* specific names the task actually needs.
|
|
23
|
+
*/
|
|
24
|
+
scrubSecrets?: boolean;
|
|
25
|
+
secretAllowlist?: string[];
|
|
17
26
|
}
|
|
18
27
|
export interface ExecOutcome {
|
|
19
28
|
code: number | null;
|
package/dist/core/exec.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* chokepoint for running worker CLIs and raw commands.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { scrubEnv } from "./security.js";
|
|
9
10
|
/**
|
|
10
11
|
* Run a command safely. `cmd` is the binary, `args` are passed verbatim.
|
|
11
12
|
* Never pass a full command string here — that is what enables injection.
|
|
@@ -13,9 +14,11 @@ import { spawn } from "node:child_process";
|
|
|
13
14
|
export function execSafe(cmd, args = [], opts = {}) {
|
|
14
15
|
const maxBuffer = opts.maxBuffer ?? 10 * 1024 * 1024;
|
|
15
16
|
return new Promise((resolve) => {
|
|
17
|
+
const baseEnv = opts.env ?? process.env;
|
|
18
|
+
const env = opts.scrubSecrets === false ? baseEnv : scrubEnv(baseEnv, opts.secretAllowlist);
|
|
16
19
|
const child = spawn(cmd, args, {
|
|
17
20
|
cwd: opts.cwd ?? process.cwd(),
|
|
18
|
-
env
|
|
21
|
+
env,
|
|
19
22
|
shell: false,
|
|
20
23
|
windowsHide: true,
|
|
21
24
|
});
|
|
@@ -78,9 +81,11 @@ export function execSafe(cmd, args = [], opts = {}) {
|
|
|
78
81
|
export function execStream(cmd, args = [], opts = {}) {
|
|
79
82
|
const maxBuffer = opts.maxBuffer ?? 20 * 1024 * 1024;
|
|
80
83
|
return new Promise((resolve) => {
|
|
84
|
+
const baseEnv = opts.env ?? process.env;
|
|
85
|
+
const env = opts.scrubSecrets === false ? baseEnv : scrubEnv(baseEnv, opts.secretAllowlist);
|
|
81
86
|
const child = spawn(cmd, args, {
|
|
82
87
|
cwd: opts.cwd ?? process.cwd(),
|
|
83
|
-
env
|
|
88
|
+
env,
|
|
84
89
|
shell: false,
|
|
85
90
|
windowsHide: true,
|
|
86
91
|
});
|
package/dist/core/launcher.js
CHANGED
|
@@ -353,7 +353,7 @@ async function connectMobile(rl) {
|
|
|
353
353
|
async function launchUi(authKey) {
|
|
354
354
|
// Reuse the daemon; open its landing page. Import lazily to avoid a cycle.
|
|
355
355
|
const { createDaemon } = await import("../server/daemon.js");
|
|
356
|
-
const daemon = createDaemon({ token: authKey, version: "0.
|
|
356
|
+
const daemon = createDaemon({ token: authKey, version: "0.6.1" });
|
|
357
357
|
try {
|
|
358
358
|
const { host, port } = await daemon.listen();
|
|
359
359
|
const url = `http://${host}:${port}/`;
|
package/dist/core/sandbox.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface SandboxPolicy {
|
|
|
15
15
|
allowedPaths: string[];
|
|
16
16
|
denyCommands: string[];
|
|
17
17
|
readOnlyCommands: string[];
|
|
18
|
+
/** Project-specific additions to the protected-path denylist (config-driven; the base list is always applied and cannot be removed). */
|
|
19
|
+
extraProtectedPaths?: string[];
|
|
18
20
|
}
|
|
19
21
|
export interface SandboxCheck {
|
|
20
22
|
allowed: boolean;
|
|
@@ -29,7 +31,7 @@ export declare const EXIT_SANDBOX = 4;
|
|
|
29
31
|
* @param cwd - Working directory that becomes the default allowed-path root.
|
|
30
32
|
* @param tier - Permission tier (defaults to **2** — scoped-write).
|
|
31
33
|
*/
|
|
32
|
-
export declare function createPolicy(cwd: string, tier?: PermissionTier): SandboxPolicy;
|
|
34
|
+
export declare function createPolicy(cwd: string, tier?: PermissionTier, extraProtectedPaths?: string[]): SandboxPolicy;
|
|
33
35
|
/**
|
|
34
36
|
* Determine whether a **read** of `filePath` is permitted under `policy`.
|
|
35
37
|
*
|
|
@@ -63,6 +65,17 @@ export declare function checkCommand(command: string | string[], policy: Sandbox
|
|
|
63
65
|
* `shutdown`, and `reboot`.
|
|
64
66
|
*/
|
|
65
67
|
export declare function isDestructive(command: string | string[]): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Scan ingested content (README, commit messages, AGENTS.md, tool/HTTP
|
|
70
|
+
* output, recalled memory) for prompt-injection indicators before it reaches
|
|
71
|
+
* a model. Never blocks silently — it records a security event and returns
|
|
72
|
+
* the finding so the caller decides whether to warn, exclude, or gate on
|
|
73
|
+
* approval. Maps to OWASP ASI04 (Agentic Supply Chain) / ASI06 (Memory and
|
|
74
|
+
* Context Poisoning).
|
|
75
|
+
*/
|
|
76
|
+
export declare function checkContent(text: string, source: string): SandboxCheck & {
|
|
77
|
+
severity: "none" | "low" | "high";
|
|
78
|
+
};
|
|
66
79
|
/**
|
|
67
80
|
* Map a pipeline phase name to its appropriate {@link PermissionTier}.
|
|
68
81
|
*
|
package/dist/core/sandbox.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { resolve, normalize } from "node:path";
|
|
13
13
|
import { platform } from "node:os";
|
|
14
|
+
import { checkProtectedPath, scanForInjection, securityLog, makeSecurityEvent } from "./security.js";
|
|
14
15
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
15
16
|
/** Sentinel exit code returned when a sandbox violation terminates a phase. */
|
|
16
17
|
export const EXIT_SANDBOX = 4;
|
|
@@ -99,12 +100,13 @@ function isSystemPath(filePath) {
|
|
|
99
100
|
* @param cwd - Working directory that becomes the default allowed-path root.
|
|
100
101
|
* @param tier - Permission tier (defaults to **2** — scoped-write).
|
|
101
102
|
*/
|
|
102
|
-
export function createPolicy(cwd, tier = 2) {
|
|
103
|
+
export function createPolicy(cwd, tier = 2, extraProtectedPaths = []) {
|
|
103
104
|
return {
|
|
104
105
|
tier,
|
|
105
106
|
allowedPaths: [resolve(cwd)],
|
|
106
107
|
denyCommands: [...DENY_COMMANDS],
|
|
107
108
|
readOnlyCommands: [...READ_ONLY_COMMANDS],
|
|
109
|
+
extraProtectedPaths,
|
|
108
110
|
};
|
|
109
111
|
}
|
|
110
112
|
/**
|
|
@@ -130,6 +132,19 @@ export function checkRead(filePath, policy) {
|
|
|
130
132
|
*/
|
|
131
133
|
export function checkWrite(filePath, policy) {
|
|
132
134
|
const resolved = normalize(resolve(filePath));
|
|
135
|
+
// Unconditional, tier-independent denylist: dotfiles, credentials, shell
|
|
136
|
+
// profiles, and the agent's own config/rules files are NEVER writable, no
|
|
137
|
+
// matter the tier or permission mode. Re-evaluated fresh every call — never
|
|
138
|
+
// cached, so a prior approval can't be replayed against a new target.
|
|
139
|
+
const protectedCheck = checkProtectedPath(resolved, policy.extraProtectedPaths ?? []);
|
|
140
|
+
if (!protectedCheck.allowed) {
|
|
141
|
+
securityLog.record(makeSecurityEvent("path-blocked", `${resolved}: ${protectedCheck.reason}`, "high"));
|
|
142
|
+
return {
|
|
143
|
+
allowed: false,
|
|
144
|
+
tier: policy.tier,
|
|
145
|
+
reason: `Write denied: ${protectedCheck.reason}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
133
148
|
// Tier 1: read-only — no writes ever.
|
|
134
149
|
if (policy.tier === 1) {
|
|
135
150
|
return {
|
|
@@ -178,6 +193,19 @@ export function checkWrite(filePath, policy) {
|
|
|
178
193
|
*/
|
|
179
194
|
export function checkCommand(command, policy) {
|
|
180
195
|
const cmd = normalizeCommand(command);
|
|
196
|
+
// 0. Unconditional: a command that names a protected path as its target
|
|
197
|
+
// (redirection, well-known config-editing commands) is blocked regardless
|
|
198
|
+
// of tier. This closes the gap NVIDIA calls out — OS-level path protection
|
|
199
|
+
// is the primary control, but catching it at the command layer too is
|
|
200
|
+
// useful defense-in-depth against sandbox misconfiguration.
|
|
201
|
+
const redirectTarget = /(>>?|\btee\b)\s*([^\s|&;]+)/.exec(cmd);
|
|
202
|
+
if (redirectTarget) {
|
|
203
|
+
const protectedCheck = checkProtectedPath(redirectTarget[2], policy.extraProtectedPaths ?? []);
|
|
204
|
+
if (!protectedCheck.allowed) {
|
|
205
|
+
securityLog.record(makeSecurityEvent("path-blocked", `command target ${redirectTarget[2]}: ${protectedCheck.reason}`, "high"));
|
|
206
|
+
return { allowed: false, tier: policy.tier, reason: `Command denied: ${protectedCheck.reason}` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
181
209
|
// 1. Deny-list — always blocked.
|
|
182
210
|
for (const denied of policy.denyCommands) {
|
|
183
211
|
if (cmd.includes(denied)) {
|
|
@@ -240,6 +268,26 @@ export function isDestructive(command) {
|
|
|
240
268
|
const cmd = normalizeCommand(command).toLowerCase();
|
|
241
269
|
return DESTRUCTIVE_PATTERNS.some((p) => cmd.includes(p.toLowerCase()));
|
|
242
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Scan ingested content (README, commit messages, AGENTS.md, tool/HTTP
|
|
273
|
+
* output, recalled memory) for prompt-injection indicators before it reaches
|
|
274
|
+
* a model. Never blocks silently — it records a security event and returns
|
|
275
|
+
* the finding so the caller decides whether to warn, exclude, or gate on
|
|
276
|
+
* approval. Maps to OWASP ASI04 (Agentic Supply Chain) / ASI06 (Memory and
|
|
277
|
+
* Context Poisoning).
|
|
278
|
+
*/
|
|
279
|
+
export function checkContent(text, source) {
|
|
280
|
+
const finding = scanForInjection(text, source);
|
|
281
|
+
if (finding.severity !== "none") {
|
|
282
|
+
securityLog.record(makeSecurityEvent("injection-detected", `${source}: ${finding.reason}`, finding.severity === "high" ? "high" : "low"));
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
allowed: finding.severity !== "high",
|
|
286
|
+
tier: 1,
|
|
287
|
+
reason: finding.reason,
|
|
288
|
+
severity: finding.severity,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
243
291
|
/**
|
|
244
292
|
* Map a pipeline phase name to its appropriate {@link PermissionTier}.
|
|
245
293
|
*
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security — defense-in-depth for a Headroom master that can drive real AI
|
|
3
|
+
* agents with real shell/file/network access. Prompt-level instructions
|
|
4
|
+
* ("don't touch secrets") are advisory only; a compromised or over-eager
|
|
5
|
+
* agent can be talked past them. These controls enforce at the code
|
|
6
|
+
* chokepoint instead, mapped to:
|
|
7
|
+
*
|
|
8
|
+
* - NVIDIA AI Red Team's mandatory sandbox controls (network egress,
|
|
9
|
+
* no writes outside the workspace, no writes to config/dotfiles ever,
|
|
10
|
+
* no cached approvals, secret injection not inheritance).
|
|
11
|
+
* - OWASP Top 10 for Agentic Applications:
|
|
12
|
+
* ASI01 Agent Goal Hijack / ASI05 Unexpected Code Execution
|
|
13
|
+
* -> protected-path denylist + destructive-command detection (sandbox.ts)
|
|
14
|
+
* ASI02 Tool Misuse -> network egress allowlist
|
|
15
|
+
* ASI03 Identity/Privilege Abuse -> secret scrubbing for spawned processes
|
|
16
|
+
* ASI04 Agentic Supply Chain -> prompt-injection scanning of ingested
|
|
17
|
+
* content (README, commits, AGENTS.md,
|
|
18
|
+
* skills, tool output) before it reaches
|
|
19
|
+
* a model
|
|
20
|
+
* ASI06 Memory/Context Poisoning -> same scanner applied to recall memory
|
|
21
|
+
*
|
|
22
|
+
* Every check here is a pure function re-evaluated on every call — nothing is
|
|
23
|
+
* memoized or cached, so a single approval can never be replayed (NVIDIA:
|
|
24
|
+
* "approvals should never be cached or persisted").
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Paths that must NEVER be written to by the agent, regardless of tier,
|
|
28
|
+
* permission mode, or user override. This list is the "enterprise-level
|
|
29
|
+
* denylist" NVIDIA describes: it cannot be relaxed by config.
|
|
30
|
+
*/
|
|
31
|
+
export declare function baseProtectedPaths(): string[];
|
|
32
|
+
/** File name / extension patterns that are protected wherever they appear
|
|
33
|
+
* (including inside the project workspace) — agent-config and secret files. */
|
|
34
|
+
export declare const PROTECTED_NAME_PATTERNS: RegExp[];
|
|
35
|
+
export interface PathCheck {
|
|
36
|
+
allowed: boolean;
|
|
37
|
+
reason: string;
|
|
38
|
+
protectedBy?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check a write target against the protected-path denylist. `extra` allows
|
|
42
|
+
* project-specific additions (config), but the base list is always applied on
|
|
43
|
+
* top and can never be removed — this is the "enterprise policy" backstop.
|
|
44
|
+
*/
|
|
45
|
+
export declare function checkProtectedPath(targetPath: string, extra?: string[]): PathCheck;
|
|
46
|
+
export interface EgressCheck {
|
|
47
|
+
allowed: boolean;
|
|
48
|
+
reason: string;
|
|
49
|
+
}
|
|
50
|
+
/** Default-deny egress: only these hosts are reachable unless configured otherwise. */
|
|
51
|
+
export declare const DEFAULT_NET_ALLOWLIST: string[];
|
|
52
|
+
/**
|
|
53
|
+
* Check an outbound URL against the egress allowlist. Wildcards ("*.example.com")
|
|
54
|
+
* match subdomains. Default-deny: anything not listed is blocked (NVIDIA:
|
|
55
|
+
* "network connections ... should not be permitted without manual approval").
|
|
56
|
+
*/
|
|
57
|
+
export declare function checkEgress(url: string, allowlist?: string[]): EgressCheck;
|
|
58
|
+
/**
|
|
59
|
+
* Scrub secret-shaped env vars from an environment before it's handed to a
|
|
60
|
+
* spawned process. `allow` explicitly re-admits specific names the current
|
|
61
|
+
* task actually needs (secret injection, not blanket inheritance — NVIDIA).
|
|
62
|
+
*/
|
|
63
|
+
export declare function scrubEnv(env: NodeJS.ProcessEnv, allow?: string[]): NodeJS.ProcessEnv;
|
|
64
|
+
/** Redact secret-shaped substrings from text before it is logged or shown. */
|
|
65
|
+
export declare function redactSecrets(text: string): string;
|
|
66
|
+
export type InjectionSeverity = "none" | "low" | "high";
|
|
67
|
+
export interface InjectionFinding {
|
|
68
|
+
severity: InjectionSeverity;
|
|
69
|
+
matches: string[];
|
|
70
|
+
reason: string;
|
|
71
|
+
}
|
|
72
|
+
/** Scan a single blob of ingested text for injection indicators. */
|
|
73
|
+
export declare function scanForInjection(text: string, source?: string): InjectionFinding;
|
|
74
|
+
/** Scan several named blobs at once (e.g. scout findings) and keep only hits. */
|
|
75
|
+
export declare function scanAll(blobs: Record<string, string | null | undefined>): Record<string, InjectionFinding>;
|
|
76
|
+
export interface SecurityEvent {
|
|
77
|
+
ts: string;
|
|
78
|
+
kind: "path-blocked" | "egress-blocked" | "injection-detected" | "secret-scrubbed";
|
|
79
|
+
detail: string;
|
|
80
|
+
severity: "low" | "high";
|
|
81
|
+
}
|
|
82
|
+
export declare function makeSecurityEvent(kind: SecurityEvent["kind"], detail: string, severity?: SecurityEvent["severity"]): SecurityEvent;
|
|
83
|
+
/** In-memory ring buffer of recent security events, for `maq security report` / the UI. */
|
|
84
|
+
export declare class SecurityLog {
|
|
85
|
+
private cap;
|
|
86
|
+
private events;
|
|
87
|
+
constructor(cap?: number);
|
|
88
|
+
record(e: SecurityEvent): void;
|
|
89
|
+
list(): SecurityEvent[];
|
|
90
|
+
clear(): void;
|
|
91
|
+
}
|
|
92
|
+
/** Process-wide default log so independently-imported modules share one feed. */
|
|
93
|
+
export declare const securityLog: SecurityLog;
|
|
94
|
+
export interface SecurityConfigLike {
|
|
95
|
+
extraProtectedPaths: string[];
|
|
96
|
+
extraNetAllowlist: string[];
|
|
97
|
+
permissionMode: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The single source of truth for "what does MAQ enforce right now" — used by
|
|
101
|
+
* both `maq security rules` (terminal) and `GET /v1/security` (web UI), so the
|
|
102
|
+
* two surfaces can never drift out of sync with each other or with what
|
|
103
|
+
* sandbox.ts/exec.ts/tools.ts actually enforce.
|
|
104
|
+
*/
|
|
105
|
+
export declare function securityRules(cfg: SecurityConfigLike): {
|
|
106
|
+
protectedPaths: string[];
|
|
107
|
+
protectedNamePatterns: string[];
|
|
108
|
+
extraProtectedPaths: string[];
|
|
109
|
+
netAllowlist: string[];
|
|
110
|
+
permissionMode: string;
|
|
111
|
+
secretEnvScrubbing: boolean;
|
|
112
|
+
promptInjectionScanning: string[];
|
|
113
|
+
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security — defense-in-depth for a Headroom master that can drive real AI
|
|
3
|
+
* agents with real shell/file/network access. Prompt-level instructions
|
|
4
|
+
* ("don't touch secrets") are advisory only; a compromised or over-eager
|
|
5
|
+
* agent can be talked past them. These controls enforce at the code
|
|
6
|
+
* chokepoint instead, mapped to:
|
|
7
|
+
*
|
|
8
|
+
* - NVIDIA AI Red Team's mandatory sandbox controls (network egress,
|
|
9
|
+
* no writes outside the workspace, no writes to config/dotfiles ever,
|
|
10
|
+
* no cached approvals, secret injection not inheritance).
|
|
11
|
+
* - OWASP Top 10 for Agentic Applications:
|
|
12
|
+
* ASI01 Agent Goal Hijack / ASI05 Unexpected Code Execution
|
|
13
|
+
* -> protected-path denylist + destructive-command detection (sandbox.ts)
|
|
14
|
+
* ASI02 Tool Misuse -> network egress allowlist
|
|
15
|
+
* ASI03 Identity/Privilege Abuse -> secret scrubbing for spawned processes
|
|
16
|
+
* ASI04 Agentic Supply Chain -> prompt-injection scanning of ingested
|
|
17
|
+
* content (README, commits, AGENTS.md,
|
|
18
|
+
* skills, tool output) before it reaches
|
|
19
|
+
* a model
|
|
20
|
+
* ASI06 Memory/Context Poisoning -> same scanner applied to recall memory
|
|
21
|
+
*
|
|
22
|
+
* Every check here is a pure function re-evaluated on every call — nothing is
|
|
23
|
+
* memoized or cached, so a single approval can never be replayed (NVIDIA:
|
|
24
|
+
* "approvals should never be cached or persisted").
|
|
25
|
+
*/
|
|
26
|
+
import { homedir, platform } from "node:os";
|
|
27
|
+
import { join, normalize, resolve, sep } from "node:path";
|
|
28
|
+
/* ------------------------- protected paths ------------------------------ */
|
|
29
|
+
/**
|
|
30
|
+
* Paths that must NEVER be written to by the agent, regardless of tier,
|
|
31
|
+
* permission mode, or user override. This list is the "enterprise-level
|
|
32
|
+
* denylist" NVIDIA describes: it cannot be relaxed by config.
|
|
33
|
+
*/
|
|
34
|
+
export function baseProtectedPaths() {
|
|
35
|
+
const home = homedir();
|
|
36
|
+
const win = platform() === "win32";
|
|
37
|
+
const common = [
|
|
38
|
+
// secrets / credentials
|
|
39
|
+
join(home, ".ssh"),
|
|
40
|
+
join(home, ".aws"),
|
|
41
|
+
join(home, ".gnupg"),
|
|
42
|
+
join(home, ".netrc"),
|
|
43
|
+
join(home, ".npmrc"),
|
|
44
|
+
join(home, ".pypirc"),
|
|
45
|
+
join(home, ".docker", "config.json"),
|
|
46
|
+
join(home, ".kube", "config"),
|
|
47
|
+
// shell / dotfile persistence vectors
|
|
48
|
+
join(home, ".bashrc"),
|
|
49
|
+
join(home, ".bash_profile"),
|
|
50
|
+
join(home, ".zshrc"),
|
|
51
|
+
join(home, ".zprofile"),
|
|
52
|
+
join(home, ".profile"),
|
|
53
|
+
join(home, ".gitconfig"),
|
|
54
|
+
join(home, ".curlrc"),
|
|
55
|
+
join(home, ".local", "bin"),
|
|
56
|
+
// MAQ's own configuration — an agent must never edit its own permission model
|
|
57
|
+
join(home, ".maqcli"),
|
|
58
|
+
];
|
|
59
|
+
if (win) {
|
|
60
|
+
common.push(join(home, "AppData", "Roaming", "npm"), "C:\\Windows", "C:\\Program Files", "C:\\Program Files (x86)");
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
common.push("/etc", "/usr", "/bin", "/sbin", "/boot");
|
|
64
|
+
}
|
|
65
|
+
return common;
|
|
66
|
+
}
|
|
67
|
+
/** File name / extension patterns that are protected wherever they appear
|
|
68
|
+
* (including inside the project workspace) — agent-config and secret files. */
|
|
69
|
+
export const PROTECTED_NAME_PATTERNS = [
|
|
70
|
+
/^\.env(\..*)?$/i,
|
|
71
|
+
/^\.git\/config$/i,
|
|
72
|
+
/credentials(\.json)?$/i,
|
|
73
|
+
/^id_rsa(\.pub)?$/i,
|
|
74
|
+
/^id_ed25519(\.pub)?$/i,
|
|
75
|
+
/\.pem$/i,
|
|
76
|
+
/\.pfx$/i,
|
|
77
|
+
/^\.npmrc$/i,
|
|
78
|
+
/^\.pypirc$/i,
|
|
79
|
+
/^\.netrc$/i,
|
|
80
|
+
// an agent must never rewrite the rules that govern its own behavior
|
|
81
|
+
/^AGENTS\.md$/i,
|
|
82
|
+
/^CLAUDE\.md$/i,
|
|
83
|
+
/^\.cursorrules$/i,
|
|
84
|
+
/^copilot-instructions\.md$/i,
|
|
85
|
+
/^\.maq[\\/]security\.json$/i,
|
|
86
|
+
];
|
|
87
|
+
/**
|
|
88
|
+
* Check a write target against the protected-path denylist. `extra` allows
|
|
89
|
+
* project-specific additions (config), but the base list is always applied on
|
|
90
|
+
* top and can never be removed — this is the "enterprise policy" backstop.
|
|
91
|
+
*/
|
|
92
|
+
export function checkProtectedPath(targetPath, extra = []) {
|
|
93
|
+
const resolved = normalize(resolve(targetPath));
|
|
94
|
+
const win = platform() === "win32";
|
|
95
|
+
const eq = (a, b) => (win ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
96
|
+
const within = (root) => {
|
|
97
|
+
const r = normalize(resolve(root));
|
|
98
|
+
if (eq(resolved, r))
|
|
99
|
+
return true;
|
|
100
|
+
const withSep = r.endsWith(sep) ? r : r + sep;
|
|
101
|
+
return win ? resolved.toLowerCase().startsWith(withSep.toLowerCase()) : resolved.startsWith(withSep);
|
|
102
|
+
};
|
|
103
|
+
for (const root of [...baseProtectedPaths(), ...extra]) {
|
|
104
|
+
if (within(root)) {
|
|
105
|
+
return { allowed: false, reason: `protected path (never writable): ${root}`, protectedBy: root };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const base = resolved.split(/[\\/]/).pop() ?? resolved;
|
|
109
|
+
for (const pat of PROTECTED_NAME_PATTERNS) {
|
|
110
|
+
if (pat.test(base) || pat.test(resolved.replace(/\\/g, "/"))) {
|
|
111
|
+
return { allowed: false, reason: `protected file pattern (never writable): ${pat}`, protectedBy: pat.toString() };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { allowed: true, reason: "not a protected path" };
|
|
115
|
+
}
|
|
116
|
+
/** Default-deny egress: only these hosts are reachable unless configured otherwise. */
|
|
117
|
+
export const DEFAULT_NET_ALLOWLIST = [
|
|
118
|
+
// package registries an agent legitimately needs during normal dev work
|
|
119
|
+
"registry.npmjs.org",
|
|
120
|
+
"pypi.org",
|
|
121
|
+
"files.pythonhosted.org",
|
|
122
|
+
"crates.io",
|
|
123
|
+
"proxy.golang.org",
|
|
124
|
+
"github.com",
|
|
125
|
+
"raw.githubusercontent.com",
|
|
126
|
+
"api.github.com",
|
|
127
|
+
];
|
|
128
|
+
function hostOf(url) {
|
|
129
|
+
try {
|
|
130
|
+
return new URL(url).hostname.toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Check an outbound URL against the egress allowlist. Wildcards ("*.example.com")
|
|
138
|
+
* match subdomains. Default-deny: anything not listed is blocked (NVIDIA:
|
|
139
|
+
* "network connections ... should not be permitted without manual approval").
|
|
140
|
+
*/
|
|
141
|
+
export function checkEgress(url, allowlist = DEFAULT_NET_ALLOWLIST) {
|
|
142
|
+
const host = hostOf(url);
|
|
143
|
+
if (!host)
|
|
144
|
+
return { allowed: false, reason: `not a valid http(s) URL: ${url}` };
|
|
145
|
+
if (!/^https?:$/i.test(new URL(url).protocol)) {
|
|
146
|
+
return { allowed: false, reason: "only http/https egress is permitted" };
|
|
147
|
+
}
|
|
148
|
+
const match = allowlist.some((entry) => {
|
|
149
|
+
const e = entry.toLowerCase();
|
|
150
|
+
if (e.startsWith("*."))
|
|
151
|
+
return host === e.slice(2) || host.endsWith("." + e.slice(2));
|
|
152
|
+
return host === e;
|
|
153
|
+
});
|
|
154
|
+
return match
|
|
155
|
+
? { allowed: true, reason: `host allowlisted: ${host}` }
|
|
156
|
+
: { allowed: false, reason: `host not in the network allowlist: ${host} (default-deny)` };
|
|
157
|
+
}
|
|
158
|
+
/* ------------------------- secret scrubbing ------------------------------ */
|
|
159
|
+
/** Env var name patterns treated as secrets and stripped before spawning a worker. */
|
|
160
|
+
const SECRET_ENV_PATTERNS = [
|
|
161
|
+
/_KEY$/i,
|
|
162
|
+
/_SECRET$/i,
|
|
163
|
+
/_TOKEN$/i,
|
|
164
|
+
/_PASSWORD$/i,
|
|
165
|
+
/^AWS_/i,
|
|
166
|
+
/^AZURE_/i,
|
|
167
|
+
/^GCP_/i,
|
|
168
|
+
/^OPENAI_API_KEY$/i,
|
|
169
|
+
/^ANTHROPIC_API_KEY$/i,
|
|
170
|
+
/^GROQ_API_KEY$/i,
|
|
171
|
+
/^GEMINI_API_KEY$/i,
|
|
172
|
+
/^GOOGLE_API_KEY$/i,
|
|
173
|
+
/^NPM_TOKEN$/i,
|
|
174
|
+
/^GITHUB_TOKEN$/i,
|
|
175
|
+
/^MAQ_TOKEN$/i,
|
|
176
|
+
/^RELAY_TOKEN$/i,
|
|
177
|
+
];
|
|
178
|
+
/**
|
|
179
|
+
* Scrub secret-shaped env vars from an environment before it's handed to a
|
|
180
|
+
* spawned process. `allow` explicitly re-admits specific names the current
|
|
181
|
+
* task actually needs (secret injection, not blanket inheritance — NVIDIA).
|
|
182
|
+
*/
|
|
183
|
+
export function scrubEnv(env, allow = []) {
|
|
184
|
+
const allowSet = new Set(allow.map((a) => a.toUpperCase()));
|
|
185
|
+
const out = {};
|
|
186
|
+
for (const [k, v] of Object.entries(env)) {
|
|
187
|
+
if (v === undefined)
|
|
188
|
+
continue;
|
|
189
|
+
const isSecret = SECRET_ENV_PATTERNS.some((p) => p.test(k));
|
|
190
|
+
if (isSecret && !allowSet.has(k.toUpperCase()))
|
|
191
|
+
continue;
|
|
192
|
+
out[k] = v;
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/** Redact secret-shaped substrings from text before it is logged or shown. */
|
|
197
|
+
export function redactSecrets(text) {
|
|
198
|
+
let out = text;
|
|
199
|
+
// key=value / key: value pairs whose key looks like a secret
|
|
200
|
+
out = out.replace(/\b([A-Za-z_][A-Za-z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD))\s*[=:]\s*\S+/gi, "$1=***REDACTED***");
|
|
201
|
+
// common token shapes
|
|
202
|
+
out = out.replace(/\bsk-[A-Za-z0-9_-]{10,}\b/g, "sk-***REDACTED***");
|
|
203
|
+
out = out.replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "npm_***REDACTED***");
|
|
204
|
+
out = out.replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "ghp_***REDACTED***");
|
|
205
|
+
out = out.replace(/\bAKIA[0-9A-Z]{12,}\b/g, "AKIA***REDACTED***");
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Heuristic scanner for indirect prompt injection in ingested content (README,
|
|
210
|
+
* commit messages, AGENTS.md, skills, tool/HTTP output, recalled memory).
|
|
211
|
+
* This is advisory-but-loud: it never silently drops content, it flags it so
|
|
212
|
+
* the caller can warn, exclude it from context, or require approval before
|
|
213
|
+
* acting on anything it suggested. Pattern-based and dependency-free.
|
|
214
|
+
*/
|
|
215
|
+
const INJECTION_PATTERNS = [
|
|
216
|
+
{ re: /ignore\s+(all\s+)?(previous|prior|above)\s+instructions/i, severity: "high", label: "instruction override" },
|
|
217
|
+
{ re: /disregard\s+(all\s+)?(previous|prior|your)\s+(instructions|rules|guidelines)/i, severity: "high", label: "instruction override" },
|
|
218
|
+
{ re: /you\s+are\s+now\s+(a|an)\s+\w+/i, severity: "high", label: "role reassignment" },
|
|
219
|
+
{ re: /system\s*prompt\s*:/i, severity: "high", label: "fake system prompt" },
|
|
220
|
+
{ re: /\bnew\s+instructions?\b.{0,40}\bfollow\b/i, severity: "high", label: "instruction override" },
|
|
221
|
+
{ re: /reveal\s+(your\s+)?(system\s+prompt|instructions|api\s*key|secret)/i, severity: "high", label: "exfiltration request" },
|
|
222
|
+
{ re: /send\s+(this|the|your)\s+.{0,30}\bto\s+https?:\/\//i, severity: "high", label: "exfiltration instruction" },
|
|
223
|
+
{ re: /curl\s+.{0,60}\|\s*(sh|bash)/i, severity: "high", label: "pipe-to-shell" },
|
|
224
|
+
{ re: /base64\s+-d.{0,20}\|\s*(sh|bash)/i, severity: "high", label: "obfuscated execution" },
|
|
225
|
+
{ re: /rm\s+-rf\s+[~/]/i, severity: "high", label: "destructive command in content" },
|
|
226
|
+
{ re: /(disable|bypass|turn off)\s+(the\s+)?(sandbox|permission|security|safety)/i, severity: "high", label: "guardrail bypass request" },
|
|
227
|
+
{ re: /as\s+an?\s+ai\s+(with\s+)?no\s+(restrictions|limits|filters)/i, severity: "low", label: "jailbreak framing" },
|
|
228
|
+
{ re: /\[\s*(system|assistant)\s*\]/i, severity: "low", label: "fake role marker" },
|
|
229
|
+
];
|
|
230
|
+
/** Scan a single blob of ingested text for injection indicators. */
|
|
231
|
+
export function scanForInjection(text, source = "content") {
|
|
232
|
+
if (!text)
|
|
233
|
+
return { severity: "none", matches: [], reason: "empty" };
|
|
234
|
+
const matches = [];
|
|
235
|
+
let severity = "none";
|
|
236
|
+
for (const p of INJECTION_PATTERNS) {
|
|
237
|
+
if (p.re.test(text)) {
|
|
238
|
+
matches.push(p.label);
|
|
239
|
+
if (p.severity === "high")
|
|
240
|
+
severity = "high";
|
|
241
|
+
else if (severity === "none")
|
|
242
|
+
severity = "low";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
severity,
|
|
247
|
+
matches: [...new Set(matches)],
|
|
248
|
+
reason: matches.length ? `${matches.length} indicator(s) in ${source}: ${[...new Set(matches)].join(", ")}` : `no indicators found in ${source}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** Scan several named blobs at once (e.g. scout findings) and keep only hits. */
|
|
252
|
+
export function scanAll(blobs) {
|
|
253
|
+
const out = {};
|
|
254
|
+
for (const [name, text] of Object.entries(blobs)) {
|
|
255
|
+
if (!text)
|
|
256
|
+
continue;
|
|
257
|
+
const f = scanForInjection(text, name);
|
|
258
|
+
if (f.severity !== "none")
|
|
259
|
+
out[name] = f;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
export function makeSecurityEvent(kind, detail, severity = "high") {
|
|
264
|
+
return { ts: new Date().toISOString(), kind, detail, severity };
|
|
265
|
+
}
|
|
266
|
+
/** In-memory ring buffer of recent security events, for `maq security report` / the UI. */
|
|
267
|
+
export class SecurityLog {
|
|
268
|
+
cap;
|
|
269
|
+
events = [];
|
|
270
|
+
constructor(cap = 500) {
|
|
271
|
+
this.cap = cap;
|
|
272
|
+
}
|
|
273
|
+
record(e) {
|
|
274
|
+
this.events.push(e);
|
|
275
|
+
if (this.events.length > this.cap)
|
|
276
|
+
this.events.splice(0, this.events.length - this.cap);
|
|
277
|
+
}
|
|
278
|
+
list() {
|
|
279
|
+
return [...this.events];
|
|
280
|
+
}
|
|
281
|
+
clear() {
|
|
282
|
+
this.events = [];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Process-wide default log so independently-imported modules share one feed. */
|
|
286
|
+
export const securityLog = new SecurityLog();
|
|
287
|
+
/**
|
|
288
|
+
* The single source of truth for "what does MAQ enforce right now" — used by
|
|
289
|
+
* both `maq security rules` (terminal) and `GET /v1/security` (web UI), so the
|
|
290
|
+
* two surfaces can never drift out of sync with each other or with what
|
|
291
|
+
* sandbox.ts/exec.ts/tools.ts actually enforce.
|
|
292
|
+
*/
|
|
293
|
+
export function securityRules(cfg) {
|
|
294
|
+
return {
|
|
295
|
+
protectedPaths: baseProtectedPaths(),
|
|
296
|
+
protectedNamePatterns: PROTECTED_NAME_PATTERNS.map((p) => p.toString()),
|
|
297
|
+
extraProtectedPaths: cfg.extraProtectedPaths,
|
|
298
|
+
netAllowlist: [...DEFAULT_NET_ALLOWLIST, ...cfg.extraNetAllowlist],
|
|
299
|
+
permissionMode: cfg.permissionMode,
|
|
300
|
+
secretEnvScrubbing: true,
|
|
301
|
+
promptInjectionScanning: ["scout.readme", "scout.manifest", "scout.commits", "skills.files", "agents.md"],
|
|
302
|
+
};
|
|
303
|
+
}
|
package/dist/core/skills.d.ts
CHANGED
|
@@ -28,6 +28,15 @@ export interface Skill {
|
|
|
28
28
|
tier: SkillTier;
|
|
29
29
|
kind: SkillKind;
|
|
30
30
|
content: string;
|
|
31
|
+
/**
|
|
32
|
+
* Set when the file's content matched a prompt-injection heuristic. Skills
|
|
33
|
+
* (AGENTS.md, project .maq/skills/*.md) are standing instructions read into
|
|
34
|
+
* EVERY prompt — exactly the OWASP ASI04 supply-chain vector (a poisoned
|
|
35
|
+
* file committed into a cloned repo). Flagged skills are still loaded (so
|
|
36
|
+
* legitimate unusual-but-safe rules aren't silently dropped) but are marked
|
|
37
|
+
* for the caller to warn on / exclude / gate.
|
|
38
|
+
*/
|
|
39
|
+
suspicious?: boolean;
|
|
31
40
|
}
|
|
32
41
|
export interface SkillsOptions {
|
|
33
42
|
skillsDir?: string;
|
package/dist/core/skills.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { scanForInjection, securityLog, makeSecurityEvent } from "./security.js";
|
|
25
26
|
/**
|
|
26
27
|
* Curated default skills. Grounded in 2026 agent-instruction best practice:
|
|
27
28
|
* frontier models reliably follow ~150–200 instructions before adherence
|
|
@@ -164,7 +165,8 @@ export class SkillsManager {
|
|
|
164
165
|
const parts = ["Standing instructions (skills):"];
|
|
165
166
|
let budget = this.maxChars;
|
|
166
167
|
for (const s of skills) {
|
|
167
|
-
const
|
|
168
|
+
const flag = s.suspicious ? "[UNVERIFIED-SOURCE, possible injection] " : "";
|
|
169
|
+
const line = `- [${s.kind}] ${s.name}: ${flag}${s.content.trim()}`;
|
|
168
170
|
if (line.length > budget) {
|
|
169
171
|
parts.push(line.slice(0, Math.max(0, budget)) + "…");
|
|
170
172
|
break;
|
|
@@ -202,7 +204,13 @@ export class SkillsManager {
|
|
|
202
204
|
return null;
|
|
203
205
|
const raw = readFileSync(path, "utf8").slice(0, MAX_FILE_CHARS);
|
|
204
206
|
const { tier, kind, body } = parseFrontMatter(raw);
|
|
205
|
-
|
|
207
|
+
// Skill/AGENTS.md files are standing instructions read into every
|
|
208
|
+
// prompt — scan them the same way ingested repo content is scanned.
|
|
209
|
+
const finding = scanForInjection(body, `skill:${name}`);
|
|
210
|
+
if (finding.severity !== "none") {
|
|
211
|
+
securityLog.record(makeSecurityEvent("injection-detected", `${path}: ${finding.reason}`, finding.severity === "high" ? "high" : "low"));
|
|
212
|
+
}
|
|
213
|
+
return { name, path, tier, kind, content: body, suspicious: finding.severity !== "none" };
|
|
206
214
|
}
|
|
207
215
|
catch {
|
|
208
216
|
return null;
|
package/dist/core/tools.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface ToolContext {
|
|
|
26
26
|
cwd: string;
|
|
27
27
|
headroom?: Headroom;
|
|
28
28
|
allowNet?: boolean;
|
|
29
|
+
/** Egress allowlist for http_get; falls back to security.ts's DEFAULT_NET_ALLOWLIST. */
|
|
30
|
+
netAllowlist?: string[];
|
|
29
31
|
}
|
|
30
32
|
export declare function createToolRegistry(ctx: ToolContext): ToolRegistry;
|
|
31
33
|
export declare class ToolRegistry {
|
package/dist/core/tools.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
13
13
|
import { resolve, join, relative, isAbsolute } from "node:path";
|
|
14
|
+
import { checkEgress } from "./security.js";
|
|
14
15
|
const MAX_OUT = 64 * 1024;
|
|
15
16
|
/** Resolve a user path safely inside cwd; throws on traversal escape. */
|
|
16
17
|
function safePath(cwd, p) {
|
|
@@ -122,12 +123,17 @@ export function createToolRegistry(ctx) {
|
|
|
122
123
|
if (allowNet) {
|
|
123
124
|
tools.push({
|
|
124
125
|
name: "http_get",
|
|
125
|
-
description: "HTTP GET a URL and return up to 64KB of text (network access is enabled).",
|
|
126
|
+
description: "HTTP GET a URL and return up to 64KB of text (network access is enabled, egress allowlisted).",
|
|
126
127
|
parameters: { url: { type: "string", description: "http(s) URL", required: true } },
|
|
127
128
|
run: async (args) => {
|
|
128
129
|
const url = String(args.url ?? "");
|
|
129
130
|
if (!/^https?:\/\//i.test(url))
|
|
130
131
|
throw new Error("only http(s) URLs allowed");
|
|
132
|
+
// Enabling net access is not a blanket exfiltration path — every
|
|
133
|
+
// request still passes the network egress allowlist (default-deny).
|
|
134
|
+
const egress = checkEgress(url, ctx.netAllowlist);
|
|
135
|
+
if (!egress.allowed)
|
|
136
|
+
throw new Error(`blocked by network egress policy: ${egress.reason}`);
|
|
131
137
|
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
132
138
|
const text = await res.text();
|
|
133
139
|
return { status: res.status, body: text.slice(0, MAX_OUT) };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update — lets a user run `maq update` from inside the CLI instead of
|
|
3
|
+
* remembering the npm incantation. Checks the npm registry for the latest
|
|
4
|
+
* published version, compares it to the running version, and (unless
|
|
5
|
+
* --check-only) re-installs globally via npm.
|
|
6
|
+
*
|
|
7
|
+
* Network access here is a deliberate, user-initiated exception to the
|
|
8
|
+
* default-deny egress posture (core/security.ts) — it only ever talks to the
|
|
9
|
+
* npm registry, and only when the user explicitly runs this command.
|
|
10
|
+
*/
|
|
11
|
+
export interface VersionInfo {
|
|
12
|
+
current: string;
|
|
13
|
+
latest: string | null;
|
|
14
|
+
upToDate: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Returns true if `latest` is strictly newer than `current`. */
|
|
18
|
+
export declare function isNewer(current: string, latest: string): boolean;
|
|
19
|
+
/** Query the npm registry for the latest published version of `maqcli`. */
|
|
20
|
+
export declare function fetchLatestVersion(registryUrl?: string, timeoutMs?: number): Promise<string>;
|
|
21
|
+
/** Compare the running version against the registry; never throws. */
|
|
22
|
+
export declare function checkForUpdate(currentVersion: string): Promise<VersionInfo>;
|
|
23
|
+
export interface UpdateOutcome {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
message: string;
|
|
26
|
+
version?: VersionInfo;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Perform the update: check the registry, and if a newer version exists,
|
|
30
|
+
* run `npm install -g maqcli@<latest>`. `runner` is injectable for tests —
|
|
31
|
+
* it defaults to execSafe (shell:false, argument array; never string-built).
|
|
32
|
+
*/
|
|
33
|
+
export declare function performUpdate(currentVersion: string, opts?: {
|
|
34
|
+
checkOnly?: boolean;
|
|
35
|
+
runner?: (cmd: string, args: string[]) => Promise<{
|
|
36
|
+
code: number | null;
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}>;
|
|
40
|
+
}): Promise<UpdateOutcome>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update — lets a user run `maq update` from inside the CLI instead of
|
|
3
|
+
* remembering the npm incantation. Checks the npm registry for the latest
|
|
4
|
+
* published version, compares it to the running version, and (unless
|
|
5
|
+
* --check-only) re-installs globally via npm.
|
|
6
|
+
*
|
|
7
|
+
* Network access here is a deliberate, user-initiated exception to the
|
|
8
|
+
* default-deny egress posture (core/security.ts) — it only ever talks to the
|
|
9
|
+
* npm registry, and only when the user explicitly runs this command.
|
|
10
|
+
*/
|
|
11
|
+
import { execSafe } from "./exec.js";
|
|
12
|
+
/** Parse "x.y.z" into a comparable tuple; non-numeric parts sort as 0. */
|
|
13
|
+
function parseSemver(v) {
|
|
14
|
+
const parts = v.replace(/^v/, "").split(".").map((p) => parseInt(p, 10));
|
|
15
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
16
|
+
}
|
|
17
|
+
/** Returns true if `latest` is strictly newer than `current`. */
|
|
18
|
+
export function isNewer(current, latest) {
|
|
19
|
+
const a = parseSemver(current);
|
|
20
|
+
const b = parseSemver(latest);
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
if (b[i] > a[i])
|
|
23
|
+
return true;
|
|
24
|
+
if (b[i] < a[i])
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
/** Query the npm registry for the latest published version of `maqcli`. */
|
|
30
|
+
export async function fetchLatestVersion(registryUrl = "https://registry.npmjs.org/maqcli/latest", timeoutMs = 8000) {
|
|
31
|
+
const res = await fetch(registryUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new Error(`registry returned ${res.status}`);
|
|
34
|
+
const body = (await res.json());
|
|
35
|
+
if (!body.version)
|
|
36
|
+
throw new Error("registry response missing a version field");
|
|
37
|
+
return body.version;
|
|
38
|
+
}
|
|
39
|
+
/** Compare the running version against the registry; never throws. */
|
|
40
|
+
export async function checkForUpdate(currentVersion) {
|
|
41
|
+
try {
|
|
42
|
+
const latest = await fetchLatestVersion();
|
|
43
|
+
return { current: currentVersion, latest, upToDate: !isNewer(currentVersion, latest) };
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
return { current: currentVersion, latest: null, upToDate: true, error: e.message };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Perform the update: check the registry, and if a newer version exists,
|
|
51
|
+
* run `npm install -g maqcli@<latest>`. `runner` is injectable for tests —
|
|
52
|
+
* it defaults to execSafe (shell:false, argument array; never string-built).
|
|
53
|
+
*/
|
|
54
|
+
export async function performUpdate(currentVersion, opts = {}) {
|
|
55
|
+
const version = await checkForUpdate(currentVersion);
|
|
56
|
+
if (version.error) {
|
|
57
|
+
return { ok: false, message: `could not check for updates: ${version.error}`, version };
|
|
58
|
+
}
|
|
59
|
+
if (version.upToDate) {
|
|
60
|
+
return { ok: true, message: `already up to date (v${version.current})`, version };
|
|
61
|
+
}
|
|
62
|
+
if (opts.checkOnly) {
|
|
63
|
+
return { ok: true, message: `update available: v${version.current} -> v${version.latest}`, version };
|
|
64
|
+
}
|
|
65
|
+
const runner = opts.runner ?? (async (cmd, args) => execSafe(cmd, args, { timeoutMs: 120000 }));
|
|
66
|
+
const outcome = await runner("npm", ["install", "-g", `maqcli@${version.latest}`]);
|
|
67
|
+
if (outcome.code !== 0) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
message: `update to v${version.latest} failed (exit ${outcome.code}): ${outcome.stderr.slice(0, 400) || outcome.stdout.slice(0, 400)}`,
|
|
71
|
+
version,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, message: `updated v${version.current} -> v${version.latest}`, version };
|
|
75
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
12
|
import { logger } from "./core/logger.js";
|
|
13
13
|
import { detectAgents, resolveTarget } from "./core/registry.js";
|
|
14
|
-
import { loadConfig, saveConfig, setConfigKey, configPath } from "./core/config-store.js";
|
|
14
|
+
import { loadConfig, saveConfig, setConfigKey, configPath, addConfigListItem, removeConfigListItem } from "./core/config-store.js";
|
|
15
15
|
import { runScout } from "./phases/scout.js";
|
|
16
16
|
import { getProvider } from "./core/model.js";
|
|
17
17
|
import { runPlan } from "./phases/plan.js";
|
|
@@ -43,7 +43,10 @@ import { runInit } from "./core/init-wizard.js";
|
|
|
43
43
|
import { CostTracker } from "./core/cost-tracker.js";
|
|
44
44
|
import { runLauncher } from "./core/launcher.js";
|
|
45
45
|
import { runOrchestration } from "./core/orchestrator.js";
|
|
46
|
-
|
|
46
|
+
import { performUpdate } from "./core/update.js";
|
|
47
|
+
import { checkProtectedPath, scanForInjection, securityLog, securityRules, } from "./core/security.js";
|
|
48
|
+
import { readFileSync, statSync } from "node:fs";
|
|
49
|
+
const VERSION = "0.6.1";
|
|
47
50
|
async function main(argv) {
|
|
48
51
|
const [command, ...rest] = argv;
|
|
49
52
|
switch (command) {
|
|
@@ -75,6 +78,10 @@ async function main(argv) {
|
|
|
75
78
|
return cmdRun(rest);
|
|
76
79
|
case "orchestrate":
|
|
77
80
|
return cmdOrchestrate(rest);
|
|
81
|
+
case "security":
|
|
82
|
+
return cmdSecurity(rest);
|
|
83
|
+
case "update":
|
|
84
|
+
return cmdUpdate(rest);
|
|
78
85
|
case "verify":
|
|
79
86
|
return cmdVerify(rest);
|
|
80
87
|
case "serve":
|
|
@@ -319,6 +326,131 @@ async function cmdVerify(args) {
|
|
|
319
326
|
logger.out(JSON.stringify({ detectedTestCommand: testCmd, verify: result }, null, values.json ? 0 : 2));
|
|
320
327
|
return result.verified ? 0 : 3;
|
|
321
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* `maq security` — the single place both the terminal and the web UI read
|
|
331
|
+
* the SAME enforced rules from (core/security.ts), so there is no drift
|
|
332
|
+
* between what's documented and what's actually enforced at the code
|
|
333
|
+
* chokepoints (sandbox.ts / exec.ts / tools.ts / scout.ts / skills.ts).
|
|
334
|
+
*/
|
|
335
|
+
function cmdSecurity(args) {
|
|
336
|
+
const { values, positionals } = parseArgs({
|
|
337
|
+
args,
|
|
338
|
+
options: { ...commonFlags(), limit: { type: "string" } },
|
|
339
|
+
allowPositionals: true,
|
|
340
|
+
});
|
|
341
|
+
const sub = positionals[0] ?? "report";
|
|
342
|
+
const cfg = loadConfig();
|
|
343
|
+
if (sub === "rules") {
|
|
344
|
+
const rules = securityRules(cfg);
|
|
345
|
+
if (values.json) {
|
|
346
|
+
logger.out(JSON.stringify(rules, null, 0));
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
logger.out("MAQ security rules — enforced at the code level, not just prompted for.\n");
|
|
350
|
+
logger.out("Protected paths (never writable, cannot be overridden by config):");
|
|
351
|
+
for (const p of rules.protectedPaths)
|
|
352
|
+
logger.out(` - ${p}`);
|
|
353
|
+
logger.out("\nProtected file patterns (matched by name, anywhere):");
|
|
354
|
+
for (const p of rules.protectedNamePatterns)
|
|
355
|
+
logger.out(` - ${p}`);
|
|
356
|
+
if (rules.extraProtectedPaths.length) {
|
|
357
|
+
logger.out("\nProject-added protected paths (config: extraProtectedPaths):");
|
|
358
|
+
for (const p of rules.extraProtectedPaths)
|
|
359
|
+
logger.out(` - ${p}`);
|
|
360
|
+
}
|
|
361
|
+
logger.out("\nNetwork egress allowlist (default-deny everything else):");
|
|
362
|
+
for (const h of rules.netAllowlist)
|
|
363
|
+
logger.out(` - ${h}`);
|
|
364
|
+
logger.out("\nSecret-shaped env vars scrubbed from spawned workers by default.");
|
|
365
|
+
logger.out("Prompt-injection heuristics scanned on: README, manifest, git commits, AGENTS.md/skill files.");
|
|
366
|
+
logger.out(`\nPermission posture: ${cfg.permissionMode} (moderate = major/destructive actions queue for approval)`);
|
|
367
|
+
logger.out("\nmanage: maq security add|remove extraProtectedPaths|extraNetAllowlist <value>");
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
if (sub === "scan") {
|
|
371
|
+
const target = positionals[1];
|
|
372
|
+
if (!target) {
|
|
373
|
+
logger.error("usage: maq security scan <path>");
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const stat = statSync(target);
|
|
378
|
+
if (!stat.isFile()) {
|
|
379
|
+
logger.error(`not a file: ${target}`);
|
|
380
|
+
return 1;
|
|
381
|
+
}
|
|
382
|
+
const text = readFileSync(target, "utf8");
|
|
383
|
+
const pathCheck = checkProtectedPath(target, cfg.extraProtectedPaths);
|
|
384
|
+
const injection = scanForInjection(text, target);
|
|
385
|
+
const result = { path: target, protectedPath: !pathCheck.allowed, pathReason: pathCheck.reason, injection };
|
|
386
|
+
if (values.json) {
|
|
387
|
+
logger.out(JSON.stringify(result, null, 0));
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
logger.out(`scan: ${target}`);
|
|
391
|
+
logger.out(` protected path: ${pathCheck.allowed ? "no" : "YES — " + pathCheck.reason}`);
|
|
392
|
+
logger.out(` prompt injection: ${injection.severity} (${injection.reason})`);
|
|
393
|
+
}
|
|
394
|
+
return pathCheck.allowed && injection.severity !== "high" ? 0 : 2;
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
logger.error(e.message);
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (sub === "add" || sub === "remove") {
|
|
402
|
+
const key = positionals[1];
|
|
403
|
+
const value = positionals[2];
|
|
404
|
+
if (!key || !value || !["extraProtectedPaths", "extraNetAllowlist"].includes(key)) {
|
|
405
|
+
logger.error("usage: maq security [add|remove] <extraProtectedPaths|extraNetAllowlist> <value>");
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
408
|
+
const updated = sub === "add" ? addConfigListItem(key, value) : removeConfigListItem(key, value);
|
|
409
|
+
logger.out(`${key} = ${JSON.stringify(updated[key])}`);
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
// Default: report — recent security events (path blocks, egress blocks,
|
|
413
|
+
// injection detections) recorded during this process's lifetime, plus
|
|
414
|
+
// a rollup of the enforced posture.
|
|
415
|
+
const limit = values.limit ? Number(values.limit) : 20;
|
|
416
|
+
const events = securityLog.list().slice(-limit);
|
|
417
|
+
if (values.json) {
|
|
418
|
+
logger.out(JSON.stringify({ permissionMode: cfg.permissionMode, events }, null, 0));
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
logger.out("MAQ security report");
|
|
422
|
+
logger.out(` permission posture: ${cfg.permissionMode}`);
|
|
423
|
+
logger.out(` recent security events: ${events.length}`);
|
|
424
|
+
for (const e of events) {
|
|
425
|
+
logger.out(` [${e.severity.padEnd(4)}] ${e.ts} ${e.kind}: ${e.detail}`);
|
|
426
|
+
}
|
|
427
|
+
if (events.length === 0)
|
|
428
|
+
logger.out(" (none recorded this session — run a task, then check again, or 'maq security rules')");
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
/** Machine-readable rule set — shared shape with the daemon's GET /v1/security. */
|
|
432
|
+
async function cmdUpdate(args) {
|
|
433
|
+
const { values } = parseArgs({
|
|
434
|
+
args,
|
|
435
|
+
options: { ...commonFlags(), check: { type: "boolean", default: false } },
|
|
436
|
+
allowPositionals: true,
|
|
437
|
+
});
|
|
438
|
+
logger.out(`current version: ${VERSION}`);
|
|
439
|
+
logger.out(values.check ? "checking npm for a newer version…" : "checking npm and updating if a newer version is available…");
|
|
440
|
+
const outcome = await performUpdate(VERSION, { checkOnly: values.check });
|
|
441
|
+
if (values.json) {
|
|
442
|
+
logger.out(JSON.stringify(outcome, null, 0));
|
|
443
|
+
return outcome.ok ? 0 : 1;
|
|
444
|
+
}
|
|
445
|
+
logger.out(outcome.message);
|
|
446
|
+
if (outcome.ok && outcome.version && !outcome.version.upToDate && !values.check) {
|
|
447
|
+
logger.out("restart your terminal (or open a new one) to pick up the updated binary.");
|
|
448
|
+
}
|
|
449
|
+
if (outcome.ok && outcome.version && !outcome.version.upToDate && values.check) {
|
|
450
|
+
logger.out("run 'maq update' (without --check) to install it.");
|
|
451
|
+
}
|
|
452
|
+
return outcome.ok ? 0 : 1;
|
|
453
|
+
}
|
|
322
454
|
async function cmdServe(args) {
|
|
323
455
|
const { values } = parseArgs({
|
|
324
456
|
args,
|
|
@@ -1069,6 +1201,8 @@ function printHelp() {
|
|
|
1069
1201
|
" cost [report|reset] Aggregated cost tracking",
|
|
1070
1202
|
' completion <shell> Shell completions (bash/zsh/fish/powershell)',
|
|
1071
1203
|
" audit verify <run-dir> Verify a run's hash-chained audit log",
|
|
1204
|
+
" security [report|rules|scan <path>|add|remove] Enforced security rules + recent findings",
|
|
1205
|
+
" update [--check] Check npm for a newer maqcli and self-update",
|
|
1072
1206
|
" config [get|set|path|reset] Read or update ~/.maqcli/config.json",
|
|
1073
1207
|
" version | help [<topic>]",
|
|
1074
1208
|
"",
|
package/dist/phases/scout.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
|
10
10
|
import { join, relative } from "node:path";
|
|
11
11
|
import { classifyComplexity } from "../core/complexity.js";
|
|
12
12
|
import { execSafe } from "../core/exec.js";
|
|
13
|
+
import { scanAll, securityLog, makeSecurityEvent } from "../core/security.js";
|
|
13
14
|
const IGNORE = new Set([
|
|
14
15
|
"node_modules",
|
|
15
16
|
".git",
|
|
@@ -94,6 +95,21 @@ export async function runScout(task, cwd, fileCap = 400) {
|
|
|
94
95
|
if (recentCommits.length === 0)
|
|
95
96
|
notes.push("no git history (or git unavailable)");
|
|
96
97
|
notes.push(`complexity=${complexity}: ${reasons.join("; ") || "no strong signals"}`);
|
|
98
|
+
// Scan everything Scout just ingested from the repo (README, manifest,
|
|
99
|
+
// commit messages) for indirect prompt-injection before it reaches a model.
|
|
100
|
+
// A malicious README/commit is exactly the OWASP ASI04 (Agentic Supply
|
|
101
|
+
// Chain) vector: content an attacker controls (a PR, a cloned repo) that
|
|
102
|
+
// gets read into context automatically. Findings never block Scout — they
|
|
103
|
+
// are surfaced loudly so Plan/Execute treat the content with suspicion.
|
|
104
|
+
const findings = scanAll({
|
|
105
|
+
readme,
|
|
106
|
+
manifest: manifestSnippet,
|
|
107
|
+
commits: recentCommits.join("\n"),
|
|
108
|
+
});
|
|
109
|
+
for (const [source, finding] of Object.entries(findings)) {
|
|
110
|
+
notes.push(`security: possible prompt injection in ${source} (${finding.matches.join(", ")}) — treat as untrusted content`);
|
|
111
|
+
securityLog.record(makeSecurityEvent("injection-detected", `scout/${source}: ${finding.reason}`, finding.severity === "high" ? "high" : "low"));
|
|
112
|
+
}
|
|
97
113
|
return {
|
|
98
114
|
task,
|
|
99
115
|
cwd,
|
package/dist/server/daemon.js
CHANGED
|
@@ -36,6 +36,7 @@ import { InteractiveRegistry } from "../core/interactive-registry.js";
|
|
|
36
36
|
import { webuiHtml } from "./webui.js";
|
|
37
37
|
import { PermissionBroker } from "../core/permissions.js";
|
|
38
38
|
import { loadConfig } from "../core/config-store.js";
|
|
39
|
+
import { securityRules, securityLog } from "../core/security.js";
|
|
39
40
|
/** Generate a URL-safe token. */
|
|
40
41
|
export function generateToken() {
|
|
41
42
|
return randomBytes(24).toString("base64url");
|
|
@@ -51,7 +52,7 @@ export function createDaemon(opts = {}) {
|
|
|
51
52
|
const host = opts.host ?? process.env.MAQ_HOST ?? "127.0.0.1";
|
|
52
53
|
const port = opts.port ?? Number(process.env.MAQ_PORT ?? 7717);
|
|
53
54
|
const token = opts.token ?? process.env.MAQ_TOKEN ?? generateToken();
|
|
54
|
-
const version = opts.version ?? "0.
|
|
55
|
+
const version = opts.version ?? "0.6.1";
|
|
55
56
|
const corsOrigin = opts.corsOrigin ?? process.env.MAQ_CORS_ORIGIN;
|
|
56
57
|
const registry = opts.registry ?? new SessionRegistry();
|
|
57
58
|
const interactive = new InteractiveRegistry();
|
|
@@ -136,6 +137,13 @@ export function createDaemon(opts = {}) {
|
|
|
136
137
|
sendJson(res, 200, { mode: broker.getMode(), pending: broker.pending(), requests: broker.list() });
|
|
137
138
|
return;
|
|
138
139
|
}
|
|
140
|
+
// Enforced security rules + recent findings — the SAME data `maq security
|
|
141
|
+
// rules`/`report` prints in the terminal, so the UI and CLI never drift.
|
|
142
|
+
if (path === "/v1/security" && method === "GET") {
|
|
143
|
+
const cfg = loadConfig();
|
|
144
|
+
sendJson(res, 200, { rules: securityRules(cfg), events: securityLog.list().slice(-100) });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
139
147
|
const reqMatch = /^\/v1\/requests\/([^/]+)$/.exec(path);
|
|
140
148
|
if (reqMatch && method === "POST") {
|
|
141
149
|
const id = decodeURIComponent(reqMatch[1]);
|
package/dist/server/webui.js
CHANGED
|
@@ -104,6 +104,14 @@ export function webuiHtml(version) {
|
|
|
104
104
|
.req .btns{display:flex;gap:6px}
|
|
105
105
|
.req button{border:none;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px}
|
|
106
106
|
.ok-btn{background:var(--ok);color:#00231a} .no-btn{background:var(--red);color:#2a0007}
|
|
107
|
+
.sechead{display:flex;align-items:center;gap:8px;margin:18px 0 8px}
|
|
108
|
+
.sechead:first-child{margin-top:0}
|
|
109
|
+
.sechead .n{background:var(--panel2);border:1px solid var(--edge);border-radius:999px;padding:2px 9px;font-size:11px;color:var(--mut)}
|
|
110
|
+
.seclist{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:6px}
|
|
111
|
+
.secitem{background:var(--panel);border:1px solid var(--edge);border-radius:8px;padding:8px 10px;font-family:ui-monospace,monospace;font-size:12px;color:var(--ink);word-break:break-all}
|
|
112
|
+
.secnote{color:var(--mut);font-size:12px;margin:4px 0 14px}
|
|
113
|
+
.pill2{display:inline-flex;align-items:center;gap:6px;background:var(--panel2);border:1px solid var(--edge);border-radius:999px;padding:5px 12px;font-size:12px;color:var(--ink);margin-right:8px}
|
|
114
|
+
.pill2.on{color:var(--ok);border-color:var(--ok)}
|
|
107
115
|
</style></head>
|
|
108
116
|
<body>
|
|
109
117
|
<header>
|
|
@@ -117,6 +125,7 @@ export function webuiHtml(version) {
|
|
|
117
125
|
<button onclick="feat('connectivity')">Connectivity</button>
|
|
118
126
|
<button onclick="feat('doctor')">Doctor</button>
|
|
119
127
|
<button onclick="feat('models')">Models</button>
|
|
128
|
+
<button onclick="openSecurity()">🔒 Security</button>
|
|
120
129
|
<button class="ghost" onclick="togglePreview()">Preview</button>
|
|
121
130
|
</div>
|
|
122
131
|
</header>
|
|
@@ -165,6 +174,15 @@ export function webuiHtml(version) {
|
|
|
165
174
|
</div>
|
|
166
175
|
<div class="grid" id="featureGrid"><div class="empty">loading…</div></div>
|
|
167
176
|
</div>
|
|
177
|
+
<div class="overlay" id="security">
|
|
178
|
+
<div class="rhead" style="padding:14px 16px">
|
|
179
|
+
<button class="ghost" onclick="closeSecurity()">← Back to home</button>
|
|
180
|
+
<b style="margin-left:12px">Security rules</b>
|
|
181
|
+
<span class="grow"></span>
|
|
182
|
+
<span class="e-mut" style="font-size:12px">enforced at the code level — same rules the terminal ('maq security') shows and enforces</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div id="securityBody" style="overflow:auto;padding:16px"><div class="empty">loading…</div></div>
|
|
185
|
+
</div>
|
|
168
186
|
<script>
|
|
169
187
|
"use strict";
|
|
170
188
|
var base = location.origin;
|
|
@@ -353,6 +371,55 @@ async function openFeatures(){
|
|
|
353
371
|
}catch(e){ grid.innerHTML='<div class="empty">'+e+'</div>'; }
|
|
354
372
|
}
|
|
355
373
|
function closeFeatures(){ document.getElementById('features').classList.remove('show'); }
|
|
374
|
+
|
|
375
|
+
async function openSecurity(){
|
|
376
|
+
document.getElementById('security').classList.add('show');
|
|
377
|
+
var box=document.getElementById('securityBody'); box.innerHTML='loading…';
|
|
378
|
+
try{
|
|
379
|
+
var d=await (await api('/v1/security')).json();
|
|
380
|
+
var r=d.rules, evs=(d.events||[]);
|
|
381
|
+
var html='';
|
|
382
|
+
html+='<div style="margin-bottom:14px">';
|
|
383
|
+
html+='<span class="pill2 on">permission posture: '+r.permissionMode+'</span>';
|
|
384
|
+
html+='<span class="pill2 on">secret env scrubbing: on</span>';
|
|
385
|
+
html+='<span class="pill2 on">prompt-injection scanning: on</span>';
|
|
386
|
+
html+='</div>';
|
|
387
|
+
|
|
388
|
+
html+=sec('Protected paths (never writable — cannot be overridden by config)', r.protectedPaths.length);
|
|
389
|
+
html+='<div class="seclist">'+r.protectedPaths.map(function(p){return '<div class="secitem">'+esc(p)+'</div>';}).join('')+'</div>';
|
|
390
|
+
html+='<div class="secnote">Enforced in sandbox.ts checkWrite(), unconditionally, before any tier logic — a compromised agent cannot argue past this with a clever prompt.</div>';
|
|
391
|
+
|
|
392
|
+
html+=sec('Protected file patterns (matched by name, anywhere in the project)', r.protectedNamePatterns.length);
|
|
393
|
+
html+='<div class="seclist">'+r.protectedNamePatterns.map(function(p){return '<div class="secitem">'+esc(p)+'</div>';}).join('')+'</div>';
|
|
394
|
+
|
|
395
|
+
if(r.extraProtectedPaths && r.extraProtectedPaths.length){
|
|
396
|
+
html+=sec('Project-added protected paths', r.extraProtectedPaths.length);
|
|
397
|
+
html+='<div class="seclist">'+r.extraProtectedPaths.map(function(p){return '<div class="secitem">'+esc(p)+'</div>';}).join('')+'</div>';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
html+=sec('Network egress allowlist (default-deny everything else)', r.netAllowlist.length);
|
|
401
|
+
html+='<div class="seclist">'+r.netAllowlist.map(function(p){return '<div class="secitem">'+esc(p)+'</div>';}).join('')+'</div>';
|
|
402
|
+
html+='<div class="secnote">Enforced in tools.ts http_get and available to any outbound call site via security.ts checkEgress(). Not just a suggestion in a prompt.</div>';
|
|
403
|
+
|
|
404
|
+
html+=sec('Prompt-injection scanning', r.promptInjectionScanning.length);
|
|
405
|
+
html+='<div class="seclist">'+r.promptInjectionScanning.map(function(p){return '<div class="secitem">'+esc(p)+'</div>';}).join('')+'</div>';
|
|
406
|
+
html+='<div class="secnote">README, manifest, git commits, AGENTS.md and skill files are scanned for override/exfiltration/jailbreak patterns before they reach a model. Findings are surfaced loudly, never silently dropped.</div>';
|
|
407
|
+
|
|
408
|
+
html+=sec('Recent findings (this session)', evs.length);
|
|
409
|
+
if(!evs.length){ html+='<div class="secnote">None recorded yet — findings appear here as tasks run.</div>'; }
|
|
410
|
+
else {
|
|
411
|
+
html+='<div>'+evs.slice().reverse().map(function(e){
|
|
412
|
+
var cls=e.severity==='high'?'e-red':'e-warn';
|
|
413
|
+
return '<div class="ev"><span class="ic '+cls+'">⚠</span><span class="tx '+cls+'">'+esc(e.kind)+': '+esc(e.detail)+'</span></div>';
|
|
414
|
+
}).join('')+'</div>';
|
|
415
|
+
}
|
|
416
|
+
}catch(e){ box.innerHTML='<div class="empty">'+e+'</div>'; return; }
|
|
417
|
+
box.innerHTML = html;
|
|
418
|
+
}
|
|
419
|
+
function sec(title,count){ return '<div class="sechead"><b>'+title+'</b><span class="n">'+count+'</span></div>'; }
|
|
420
|
+
function esc(s){ var d=document.createElement('div'); d.textContent=String(s); return d.innerHTML; }
|
|
421
|
+
function closeSecurity(){ document.getElementById('security').classList.remove('show'); }
|
|
422
|
+
|
|
356
423
|
function runFeature(c){
|
|
357
424
|
closeFeatures();
|
|
358
425
|
if(c.needsInput==='task'||c.needsInput==='query'){
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maqcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "MAQ master orchestrator — a token-efficient, agent-agnostic supervisor CLI that sits on top of any worker CLI (AI or not) via a Scout -> Plan -> Execute -> Verify pipeline.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|