maqcli 0.4.0 → 0.6.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.
@@ -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
+ }
@@ -66,6 +66,8 @@ export interface CreateOptions {
66
66
  maxRounds?: number;
67
67
  maxIterations?: number;
68
68
  maxConcurrency?: number;
69
+ /** Permission gate for major orchestration steps (moderate mode). */
70
+ requestPermission?: (action: string, detail: string, risk: "low" | "major" | "destructive") => Promise<boolean>;
69
71
  }
70
72
  type Listener = (e: MaqEvent) => void;
71
73
  /** Thrown through the pipeline checkpoint when a session is cancelled. */
@@ -160,6 +160,7 @@ export class SessionRegistry {
160
160
  onEvent,
161
161
  signal: control.abort.signal,
162
162
  checkpoint,
163
+ requestPermission: opts.requestPermission,
163
164
  }).then((orchestration) => {
164
165
  if (session.status === "cancelled")
165
166
  return;
@@ -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;
@@ -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 line = `- [${s.kind}] ${s.name}: ${s.content.trim()}`;
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
- return { name, path, tier, kind, content: body };
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;
@@ -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 {
@@ -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) };
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,9 @@ 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
- const VERSION = "0.4.0";
46
+ import { checkProtectedPath, scanForInjection, securityLog, securityRules, } from "./core/security.js";
47
+ import { readFileSync, statSync } from "node:fs";
48
+ const VERSION = "0.6.0";
47
49
  async function main(argv) {
48
50
  const [command, ...rest] = argv;
49
51
  switch (command) {
@@ -75,6 +77,8 @@ async function main(argv) {
75
77
  return cmdRun(rest);
76
78
  case "orchestrate":
77
79
  return cmdOrchestrate(rest);
80
+ case "security":
81
+ return cmdSecurity(rest);
78
82
  case "verify":
79
83
  return cmdVerify(rest);
80
84
  case "serve":
@@ -319,6 +323,109 @@ async function cmdVerify(args) {
319
323
  logger.out(JSON.stringify({ detectedTestCommand: testCmd, verify: result }, null, values.json ? 0 : 2));
320
324
  return result.verified ? 0 : 3;
321
325
  }
326
+ /**
327
+ * `maq security` — the single place both the terminal and the web UI read
328
+ * the SAME enforced rules from (core/security.ts), so there is no drift
329
+ * between what's documented and what's actually enforced at the code
330
+ * chokepoints (sandbox.ts / exec.ts / tools.ts / scout.ts / skills.ts).
331
+ */
332
+ function cmdSecurity(args) {
333
+ const { values, positionals } = parseArgs({
334
+ args,
335
+ options: { ...commonFlags(), limit: { type: "string" } },
336
+ allowPositionals: true,
337
+ });
338
+ const sub = positionals[0] ?? "report";
339
+ const cfg = loadConfig();
340
+ if (sub === "rules") {
341
+ const rules = securityRules(cfg);
342
+ if (values.json) {
343
+ logger.out(JSON.stringify(rules, null, 0));
344
+ return 0;
345
+ }
346
+ logger.out("MAQ security rules — enforced at the code level, not just prompted for.\n");
347
+ logger.out("Protected paths (never writable, cannot be overridden by config):");
348
+ for (const p of rules.protectedPaths)
349
+ logger.out(` - ${p}`);
350
+ logger.out("\nProtected file patterns (matched by name, anywhere):");
351
+ for (const p of rules.protectedNamePatterns)
352
+ logger.out(` - ${p}`);
353
+ if (rules.extraProtectedPaths.length) {
354
+ logger.out("\nProject-added protected paths (config: extraProtectedPaths):");
355
+ for (const p of rules.extraProtectedPaths)
356
+ logger.out(` - ${p}`);
357
+ }
358
+ logger.out("\nNetwork egress allowlist (default-deny everything else):");
359
+ for (const h of rules.netAllowlist)
360
+ logger.out(` - ${h}`);
361
+ logger.out("\nSecret-shaped env vars scrubbed from spawned workers by default.");
362
+ logger.out("Prompt-injection heuristics scanned on: README, manifest, git commits, AGENTS.md/skill files.");
363
+ logger.out(`\nPermission posture: ${cfg.permissionMode} (moderate = major/destructive actions queue for approval)`);
364
+ logger.out("\nmanage: maq security add|remove extraProtectedPaths|extraNetAllowlist <value>");
365
+ return 0;
366
+ }
367
+ if (sub === "scan") {
368
+ const target = positionals[1];
369
+ if (!target) {
370
+ logger.error("usage: maq security scan <path>");
371
+ return 1;
372
+ }
373
+ try {
374
+ const stat = statSync(target);
375
+ if (!stat.isFile()) {
376
+ logger.error(`not a file: ${target}`);
377
+ return 1;
378
+ }
379
+ const text = readFileSync(target, "utf8");
380
+ const pathCheck = checkProtectedPath(target, cfg.extraProtectedPaths);
381
+ const injection = scanForInjection(text, target);
382
+ const result = { path: target, protectedPath: !pathCheck.allowed, pathReason: pathCheck.reason, injection };
383
+ if (values.json) {
384
+ logger.out(JSON.stringify(result, null, 0));
385
+ }
386
+ else {
387
+ logger.out(`scan: ${target}`);
388
+ logger.out(` protected path: ${pathCheck.allowed ? "no" : "YES — " + pathCheck.reason}`);
389
+ logger.out(` prompt injection: ${injection.severity} (${injection.reason})`);
390
+ }
391
+ return pathCheck.allowed && injection.severity !== "high" ? 0 : 2;
392
+ }
393
+ catch (e) {
394
+ logger.error(e.message);
395
+ return 1;
396
+ }
397
+ }
398
+ if (sub === "add" || sub === "remove") {
399
+ const key = positionals[1];
400
+ const value = positionals[2];
401
+ if (!key || !value || !["extraProtectedPaths", "extraNetAllowlist"].includes(key)) {
402
+ logger.error("usage: maq security [add|remove] <extraProtectedPaths|extraNetAllowlist> <value>");
403
+ return 1;
404
+ }
405
+ const updated = sub === "add" ? addConfigListItem(key, value) : removeConfigListItem(key, value);
406
+ logger.out(`${key} = ${JSON.stringify(updated[key])}`);
407
+ return 0;
408
+ }
409
+ // Default: report — recent security events (path blocks, egress blocks,
410
+ // injection detections) recorded during this process's lifetime, plus
411
+ // a rollup of the enforced posture.
412
+ const limit = values.limit ? Number(values.limit) : 20;
413
+ const events = securityLog.list().slice(-limit);
414
+ if (values.json) {
415
+ logger.out(JSON.stringify({ permissionMode: cfg.permissionMode, events }, null, 0));
416
+ return 0;
417
+ }
418
+ logger.out("MAQ security report");
419
+ logger.out(` permission posture: ${cfg.permissionMode}`);
420
+ logger.out(` recent security events: ${events.length}`);
421
+ for (const e of events) {
422
+ logger.out(` [${e.severity.padEnd(4)}] ${e.ts} ${e.kind}: ${e.detail}`);
423
+ }
424
+ if (events.length === 0)
425
+ logger.out(" (none recorded this session — run a task, then check again, or 'maq security rules')");
426
+ return 0;
427
+ }
428
+ /** Machine-readable rule set — shared shape with the daemon's GET /v1/security. */
322
429
  async function cmdServe(args) {
323
430
  const { values } = parseArgs({
324
431
  args,
@@ -1069,6 +1176,7 @@ function printHelp() {
1069
1176
  " cost [report|reset] Aggregated cost tracking",
1070
1177
  ' completion <shell> Shell completions (bash/zsh/fish/powershell)',
1071
1178
  " audit verify <run-dir> Verify a run's hash-chained audit log",
1179
+ " security [report|rules|scan <path>|add|remove] Enforced security rules + recent findings",
1072
1180
  " config [get|set|path|reset] Read or update ~/.maqcli/config.json",
1073
1181
  " version | help [<topic>]",
1074
1182
  "",
@@ -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,
@@ -34,6 +34,9 @@ import { execSafe } from "../core/exec.js";
34
34
  import { commandCatalog, maqCommands } from "../core/command-catalog.js";
35
35
  import { InteractiveRegistry } from "../core/interactive-registry.js";
36
36
  import { webuiHtml } from "./webui.js";
37
+ import { PermissionBroker } from "../core/permissions.js";
38
+ import { loadConfig } from "../core/config-store.js";
39
+ import { securityRules, securityLog } from "../core/security.js";
37
40
  /** Generate a URL-safe token. */
38
41
  export function generateToken() {
39
42
  return randomBytes(24).toString("base64url");
@@ -49,11 +52,17 @@ export function createDaemon(opts = {}) {
49
52
  const host = opts.host ?? process.env.MAQ_HOST ?? "127.0.0.1";
50
53
  const port = opts.port ?? Number(process.env.MAQ_PORT ?? 7717);
51
54
  const token = opts.token ?? process.env.MAQ_TOKEN ?? generateToken();
52
- const version = opts.version ?? "0.4.0";
55
+ const version = opts.version ?? "0.6.0";
53
56
  const corsOrigin = opts.corsOrigin ?? process.env.MAQ_CORS_ORIGIN;
54
57
  const registry = opts.registry ?? new SessionRegistry();
55
58
  const interactive = new InteractiveRegistry();
56
59
  const startedAt = Date.now();
60
+ // The request-box. Posture comes from config (set by the guided launcher).
61
+ const permissionMode = (() => {
62
+ const m = loadConfig().permissionMode;
63
+ return m === "full" ? "full" : "moderate";
64
+ })();
65
+ const broker = new PermissionBroker(permissionMode);
57
66
  // Track live SSE responses so shutdown can end them deterministically.
58
67
  // Without this, server.close() blocks forever on the long-lived event
59
68
  // streams (they only emit a keep-alive ping every 15s and never end on their
@@ -123,6 +132,35 @@ export function createDaemon(opts = {}) {
123
132
  sendJson(res, 200, commandCatalog());
124
133
  return;
125
134
  }
135
+ // The permission request-box.
136
+ if (path === "/v1/requests" && method === "GET") {
137
+ sendJson(res, 200, { mode: broker.getMode(), pending: broker.pending(), requests: broker.list() });
138
+ return;
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
+ }
147
+ const reqMatch = /^\/v1\/requests\/([^/]+)$/.exec(path);
148
+ if (reqMatch && method === "POST") {
149
+ const id = decodeURIComponent(reqMatch[1]);
150
+ const body = await readJson(req);
151
+ const action = String(body.action ?? "");
152
+ let ok = false;
153
+ if (action === "approve")
154
+ ok = broker.approve(id, "web");
155
+ else if (action === "deny")
156
+ ok = broker.deny(id, "web");
157
+ else {
158
+ sendJson(res, 400, { error: "action must be approve|deny" });
159
+ return;
160
+ }
161
+ sendJson(res, ok ? 202 : 409, { id, action, ok, request: broker.get(id) });
162
+ return;
163
+ }
126
164
  // Whitelisted CLI runner — powers the app's Master (terminal) edition.
127
165
  // Only known `maq` subcommands run; never arbitrary shell.
128
166
  if (path === "/v1/exec" && method === "POST") {
@@ -209,6 +247,9 @@ export function createDaemon(opts = {}) {
209
247
  maxRounds: typeof body.maxRounds === "number" ? body.maxRounds : undefined,
210
248
  maxIterations: typeof body.maxIterations === "number" ? body.maxIterations : undefined,
211
249
  maxConcurrency: typeof body.maxConcurrency === "number" ? body.maxConcurrency : undefined,
250
+ // Major orchestration steps pass through the request-box, judged
251
+ // against this session's goal.
252
+ requestPermission: (action, detail, risk) => broker.gate(action, detail, { risk, goal: task }),
212
253
  });
213
254
  sendJson(res, 201, registry.summarize(s));
214
255
  return;