feat(agent): add exec approval type definitions and safety evaluation engine
Introduces the core exec approval system with: - Type definitions: ExecSecurity, ExecAsk, ApprovalDecision, ExecApprovalConfig - Command safety evaluation: shell syntax analysis, safe binary detection, dangerous pattern detection, allowlist matching - Persistent allowlist management: glob pattern matching, dedup, usage tracking - Comprehensive test coverage (76 tests) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c70dd338c2
commit
e67682cfa0
5 changed files with 1080 additions and 0 deletions
165
src/agent/tools/exec-allowlist.ts
Normal file
165
src/agent/tools/exec-allowlist.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Exec Allowlist — Persistent command pattern matching and management
|
||||
*
|
||||
* Allowlist entries use glob-like patterns to match against commands.
|
||||
* Patterns are matched against the full command string or binary name.
|
||||
*/
|
||||
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { ExecAllowlistEntry } from "./exec-approval-types.js";
|
||||
|
||||
/**
|
||||
* Match a command against allowlist entries.
|
||||
* Returns the first matching entry, or null if no match.
|
||||
*
|
||||
* Matching rules:
|
||||
* - Patterns are case-insensitive
|
||||
* - "*" matches any sequence of non-space characters (within a segment)
|
||||
* - "**" matches any sequence (including spaces)
|
||||
* - Exact match on the full command or command prefix
|
||||
* - Pattern "git *" matches "git status", "git log", etc.
|
||||
*/
|
||||
export function matchAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
command: string,
|
||||
): ExecAllowlistEntry | null {
|
||||
const normalizedCommand = command.trim().toLowerCase();
|
||||
if (!normalizedCommand) return null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (matchPattern(entry.pattern, normalizedCommand)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a glob-like pattern against a command string.
|
||||
*/
|
||||
function matchPattern(pattern: string, command: string): boolean {
|
||||
const normalizedPattern = pattern.trim().toLowerCase();
|
||||
if (!normalizedPattern) return false;
|
||||
|
||||
// Convert glob pattern to regex
|
||||
let regexStr = "^";
|
||||
let i = 0;
|
||||
while (i < normalizedPattern.length) {
|
||||
const ch = normalizedPattern[i]!;
|
||||
|
||||
if (ch === "*") {
|
||||
if (normalizedPattern[i + 1] === "*") {
|
||||
// ** matches anything (including spaces)
|
||||
regexStr += ".*";
|
||||
i += 2;
|
||||
} else {
|
||||
// * matches non-space characters
|
||||
regexStr += "[^\\s]*";
|
||||
i += 1;
|
||||
}
|
||||
} else if (ch === "?") {
|
||||
regexStr += "[^\\s]";
|
||||
i += 1;
|
||||
} else {
|
||||
// Escape regex special characters
|
||||
regexStr += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
regexStr += "$";
|
||||
|
||||
try {
|
||||
return new RegExp(regexStr).test(command);
|
||||
} catch {
|
||||
// Fallback to exact match if regex is invalid
|
||||
return normalizedPattern === command;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry to the allowlist.
|
||||
* Deduplicates by pattern (case-insensitive).
|
||||
* Returns the updated entries array.
|
||||
*/
|
||||
export function addAllowlistEntry(
|
||||
entries: ExecAllowlistEntry[],
|
||||
pattern: string,
|
||||
): ExecAllowlistEntry[] {
|
||||
const normalizedPattern = pattern.trim().toLowerCase();
|
||||
|
||||
// Check for duplicate
|
||||
const existing = entries.find(
|
||||
(e) => e.pattern.trim().toLowerCase() === normalizedPattern,
|
||||
);
|
||||
if (existing) return entries;
|
||||
|
||||
const newEntry: ExecAllowlistEntry = {
|
||||
id: uuidv7(),
|
||||
pattern: pattern.trim(),
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
|
||||
return [...entries, newEntry];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage of an allowlist entry.
|
||||
* Updates lastUsedAt and lastUsedCommand.
|
||||
* Returns the updated entries array.
|
||||
*/
|
||||
export function recordAllowlistUse(
|
||||
entries: ExecAllowlistEntry[],
|
||||
entry: ExecAllowlistEntry,
|
||||
command: string,
|
||||
): ExecAllowlistEntry[] {
|
||||
return entries.map((e) => {
|
||||
if (e === entry || (e.id && e.id === entry.id) || e.pattern === entry.pattern) {
|
||||
return {
|
||||
...e,
|
||||
lastUsedAt: Date.now(),
|
||||
lastUsedCommand: command,
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an allowlist entry by pattern or ID.
|
||||
* Returns the updated entries array.
|
||||
*/
|
||||
export function removeAllowlistEntry(
|
||||
entries: ExecAllowlistEntry[],
|
||||
patternOrId: string,
|
||||
): ExecAllowlistEntry[] {
|
||||
const normalized = patternOrId.trim().toLowerCase();
|
||||
return entries.filter(
|
||||
(e) =>
|
||||
e.pattern.trim().toLowerCase() !== normalized &&
|
||||
e.id !== patternOrId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize allowlist entries: assign missing IDs, deduplicate.
|
||||
*/
|
||||
export function normalizeAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
): ExecAllowlistEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ExecAllowlistEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = entry.pattern.trim().toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
result.push({
|
||||
...entry,
|
||||
id: entry.id ?? uuidv7(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue