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,147 @@
1
+ /**
2
+ * permissions — the "request box" the spec describes.
3
+ *
4
+ * A single Headroom master controls everything, but it never performs work
5
+ * itself; its ants (workers) do. So permission is about gating the ants' major
6
+ * actions, not the master. Two postures:
7
+ *
8
+ * full — everything is allowed; the box stays empty.
9
+ * moderate — every MAJOR or DESTRUCTIVE action stops and is filed as a
10
+ * request. A goal-aware policy (the Headroom check) auto-approves
11
+ * actions that clearly serve the stated goal and holds the rest for
12
+ * an explicit approve/deny (by the master or a human via the UI).
13
+ *
14
+ * Requests are held in an in-memory box; each pending request exposes a promise
15
+ * (`await`) that resolves when it is decided. Low-risk actions never queue.
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+ /** Classify an action string into a risk level (deterministic, pattern-based). */
19
+ export function classifyRisk(action, detail = "") {
20
+ const s = `${action} ${detail}`.toLowerCase();
21
+ if (/(rm\s+-rf|rmdir\s+\/s|drop\s+(table|database)|truncate|del\s+\/|format\s|mkfs|:\s*>\s|force[-\s]?push|--force\b|\bpush\b.*\s-f\b|reset\s+--hard|git\s+clean\s+-|delete\s+from|shutdown|reboot)/.test(s)) {
22
+ return "destructive";
23
+ }
24
+ if (/(write|create|modify|edit|install|npm\s+i|pip\s+install|deploy|publish|push|commit|merge|migrate|chmod|chown|mv\s|move|rename|apply|patch)/.test(s)) {
25
+ return "major";
26
+ }
27
+ return "low";
28
+ }
29
+ /**
30
+ * The Headroom check: allow when the action plainly serves the goal.
31
+ * - low risk → always allow
32
+ * - destructive → never auto-allow (must be approved explicitly)
33
+ * - major → allow only if it aligns with the goal (keyword overlap)
34
+ */
35
+ export function goalAwarePolicy(req) {
36
+ if (req.risk === "low")
37
+ return { allow: true, reason: "low-risk action" };
38
+ if (req.risk === "destructive")
39
+ return { allow: false, reason: "destructive action requires explicit approval" };
40
+ // major: align against the goal.
41
+ const goal = (req.goal ?? "").toLowerCase();
42
+ if (!goal)
43
+ return { allow: false, reason: "no goal context to justify a major action" };
44
+ const words = new Set((req.action + " " + req.detail).toLowerCase().split(/\W+/).filter((w) => w.length >= 4));
45
+ const goalWords = new Set(goal.split(/\W+/).filter((w) => w.length >= 4));
46
+ let overlap = 0;
47
+ for (const w of words)
48
+ if (goalWords.has(w))
49
+ overlap++;
50
+ return overlap > 0
51
+ ? { allow: true, reason: `major action aligns with the goal (${overlap} matching term(s))` }
52
+ : { allow: false, reason: "major action does not clearly serve the stated goal" };
53
+ }
54
+ export class PermissionBroker {
55
+ mode;
56
+ policy;
57
+ box = new Map();
58
+ waiters = new Map();
59
+ constructor(mode = "moderate", opts = {}) {
60
+ this.mode = mode;
61
+ this.policy = opts.policy ?? goalAwarePolicy;
62
+ }
63
+ getMode() {
64
+ return this.mode;
65
+ }
66
+ /**
67
+ * File a request. Returns the (possibly already-decided) request. In `full`
68
+ * mode everything is approved immediately; in `moderate` mode the policy runs
69
+ * and only holds what it cannot justify.
70
+ */
71
+ request(action, detail, ctx = {}) {
72
+ const risk = ctx.risk ?? classifyRisk(action, detail);
73
+ const req = {
74
+ id: randomUUID(),
75
+ action,
76
+ detail,
77
+ risk,
78
+ goal: ctx.goal,
79
+ status: "pending",
80
+ reason: "",
81
+ ts: new Date().toISOString(),
82
+ };
83
+ if (this.mode === "full") {
84
+ req.status = "approved";
85
+ req.reason = "full-permission mode";
86
+ this.box.set(req.id, req);
87
+ return req;
88
+ }
89
+ const verdict = this.policy({ action, detail, risk, goal: ctx.goal });
90
+ if (verdict.allow) {
91
+ req.status = "approved";
92
+ req.reason = verdict.reason;
93
+ }
94
+ else {
95
+ req.status = "pending";
96
+ req.reason = verdict.reason;
97
+ }
98
+ this.box.set(req.id, req);
99
+ return req;
100
+ }
101
+ /** Resolve once the request is decided. Already-decided requests resolve now. */
102
+ await(id) {
103
+ const req = this.box.get(id);
104
+ if (!req)
105
+ return Promise.resolve(false);
106
+ if (req.status !== "pending")
107
+ return Promise.resolve(req.status === "approved");
108
+ return new Promise((resolve) => {
109
+ const list = this.waiters.get(id) ?? [];
110
+ list.push({ resolve });
111
+ this.waiters.set(id, list);
112
+ });
113
+ }
114
+ /** Convenience: file + await in one call. */
115
+ async gate(action, detail, ctx = {}) {
116
+ const req = this.request(action, detail, ctx);
117
+ return this.await(req.id);
118
+ }
119
+ approve(id, by = "user") {
120
+ return this.decide(id, "approved", by);
121
+ }
122
+ deny(id, by = "user") {
123
+ return this.decide(id, "denied", by);
124
+ }
125
+ decide(id, status, by) {
126
+ const req = this.box.get(id);
127
+ if (!req || req.status !== "pending")
128
+ return false;
129
+ req.status = status;
130
+ req.decidedBy = by;
131
+ req.reason = `${status} by ${by}`;
132
+ const waiters = this.waiters.get(id) ?? [];
133
+ for (const w of waiters)
134
+ w.resolve(status === "approved");
135
+ this.waiters.delete(id);
136
+ return true;
137
+ }
138
+ pending() {
139
+ return [...this.box.values()].filter((r) => r.status === "pending");
140
+ }
141
+ list() {
142
+ return [...this.box.values()];
143
+ }
144
+ get(id) {
145
+ return this.box.get(id);
146
+ }
147
+ }
@@ -50,6 +50,7 @@ export interface CatalogProvider {
50
50
  */
51
51
  export declare const PROVIDER_CATALOG: CatalogProvider[];
52
52
  export declare function getCatalogProvider(id: string): CatalogProvider | undefined;
53
+ export declare function providerGoodFor(id: string): string[];
53
54
  /**
54
55
  * Detect which catalog providers are usable RIGHT NOW from the environment
55
56
  * only — no network, no tokens. Local providers (Ollama) are reported as
@@ -156,6 +156,24 @@ export const PROVIDER_CATALOG = [
156
156
  export function getCatalogProvider(id) {
157
157
  return PROVIDER_CATALOG.find((p) => p.id === id);
158
158
  }
159
+ /**
160
+ * The role each provider tends to play in the pipeline (fed into the Headroom
161
+ * knowledge doc). Roles: plan | code | review | summarize | fan-out.
162
+ */
163
+ const PROVIDER_GOOD_FOR = {
164
+ openai: ["plan", "code", "review"],
165
+ anthropic: ["plan", "code", "review", "summarize"],
166
+ gemini: ["summarize", "code", "fan-out"],
167
+ groq: ["fan-out", "summarize"],
168
+ xai: ["plan", "code"],
169
+ deepseek: ["code", "review"],
170
+ mistral: ["code", "summarize"],
171
+ ollama: ["fan-out", "summarize"],
172
+ litellm: ["plan", "code", "review"],
173
+ };
174
+ export function providerGoodFor(id) {
175
+ return PROVIDER_GOOD_FOR[id] ?? ["code"];
176
+ }
159
177
  /**
160
178
  * Detect which catalog providers are usable RIGHT NOW from the environment
161
179
  * only — no network, no tokens. Local providers (Ollama) are reported as
@@ -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
  *
@@ -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
+ };