@heretyc/subagent-mcp 2.12.3 → 2.12.5-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -163
- package/dist/advanced-ruleset.py +67 -67
- package/dist/concurrency.js +325 -11
- package/dist/config-scaffold.js +2 -2
- package/dist/drivers.js +340 -30
- package/dist/effort.js +10 -5
- package/dist/global-concurrency.jsonc +39 -34
- package/dist/global-subagent-mcp-config.jsonc +39 -0
- package/dist/index.js +242 -32
- package/dist/pending-permissions.js +193 -0
- package/dist/permission-classes.json +36 -0
- package/dist/permission-engine.js +253 -0
- package/dist/routing-table.json +3561 -3561
- package/dist/ruleset-scaffold.js +1 -1
- package/dist/setup.js +1 -1
- package/dist/status-helpers.js +10 -2
- package/dist/wait-helpers.js +3 -0
- package/dist/zombie.js +16 -3
- package/package.json +69 -69
- package/scripts/postinstall.mjs +102 -102
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const PARK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
3
|
+
const PER_AGENT_FIFO_CAP = 16;
|
|
4
|
+
function publicRecord(record) {
|
|
5
|
+
return { ...record };
|
|
6
|
+
}
|
|
7
|
+
function shouldEscalateToHuman(input) {
|
|
8
|
+
return (input.permission_ceiling === "auto" &&
|
|
9
|
+
input.escalation !== "off" &&
|
|
10
|
+
input.irreversible === true);
|
|
11
|
+
}
|
|
12
|
+
export class PendingPermissionManager {
|
|
13
|
+
pendingById = new Map();
|
|
14
|
+
pendingByAgent = new Map();
|
|
15
|
+
askedCountByAgent = new Map();
|
|
16
|
+
history = [];
|
|
17
|
+
queueListeners = new Set();
|
|
18
|
+
telemetry = {
|
|
19
|
+
cap_overflow_auto_denies: 0,
|
|
20
|
+
park_timeout_auto_denies: 0,
|
|
21
|
+
};
|
|
22
|
+
onAgentQueueChange(listener) {
|
|
23
|
+
this.queueListeners.add(listener);
|
|
24
|
+
return () => this.queueListeners.delete(listener);
|
|
25
|
+
}
|
|
26
|
+
create(input) {
|
|
27
|
+
const queue = this.pendingByAgent.get(input.agent_id) ?? [];
|
|
28
|
+
const asked = (this.askedCountByAgent.get(input.agent_id) ?? 0) + 1;
|
|
29
|
+
this.askedCountByAgent.set(input.agent_id, asked);
|
|
30
|
+
if (queue.length >= PER_AGENT_FIFO_CAP) {
|
|
31
|
+
const record = {
|
|
32
|
+
request_id: randomUUID(),
|
|
33
|
+
agent_id: input.agent_id,
|
|
34
|
+
harness_channel: input.harness_channel,
|
|
35
|
+
tool_name_or_method: input.tool_name_or_method,
|
|
36
|
+
action: input.action,
|
|
37
|
+
permission_ceiling: input.permission_ceiling,
|
|
38
|
+
escalation: input.escalation,
|
|
39
|
+
irreversible: input.irreversible,
|
|
40
|
+
escalate_to_human: shouldEscalateToHuman(input),
|
|
41
|
+
reason: input.reason,
|
|
42
|
+
suggestions: input.suggestions,
|
|
43
|
+
requested_at: Date.now(),
|
|
44
|
+
correlation_id: input.correlation_id,
|
|
45
|
+
state: "auto_answered",
|
|
46
|
+
auto_answer_rule: "pending-queue cap reached",
|
|
47
|
+
asked_count_for_this_agent: asked,
|
|
48
|
+
answered_at: Date.now(),
|
|
49
|
+
answer: "deny",
|
|
50
|
+
answer_reason: "pending-queue cap reached",
|
|
51
|
+
};
|
|
52
|
+
this.telemetry.cap_overflow_auto_denies += 1;
|
|
53
|
+
this.history.push(publicRecord(record));
|
|
54
|
+
console.error(`[permissions] auto-deny ${record.request_id} for agent ${record.agent_id}: pending-queue cap reached`);
|
|
55
|
+
void Promise.resolve(input.resolve({
|
|
56
|
+
request_id: record.request_id,
|
|
57
|
+
agent_id: record.agent_id,
|
|
58
|
+
decision: "deny",
|
|
59
|
+
reason: record.answer_reason,
|
|
60
|
+
})).catch(() => { });
|
|
61
|
+
return publicRecord(record);
|
|
62
|
+
}
|
|
63
|
+
const request_id = randomUUID();
|
|
64
|
+
const record = {
|
|
65
|
+
request_id,
|
|
66
|
+
agent_id: input.agent_id,
|
|
67
|
+
harness_channel: input.harness_channel,
|
|
68
|
+
tool_name_or_method: input.tool_name_or_method,
|
|
69
|
+
action: input.action,
|
|
70
|
+
permission_ceiling: input.permission_ceiling,
|
|
71
|
+
escalation: input.escalation,
|
|
72
|
+
irreversible: input.irreversible,
|
|
73
|
+
escalate_to_human: shouldEscalateToHuman(input),
|
|
74
|
+
reason: input.reason,
|
|
75
|
+
suggestions: input.suggestions,
|
|
76
|
+
requested_at: Date.now(),
|
|
77
|
+
correlation_id: input.correlation_id,
|
|
78
|
+
state: "pending",
|
|
79
|
+
asked_count_for_this_agent: asked,
|
|
80
|
+
resolve: input.resolve,
|
|
81
|
+
timer: setTimeout(() => {
|
|
82
|
+
void this.autoDeny(request_id, "park-timeout: no decision within 5 minutes; auto-denied fail-closed");
|
|
83
|
+
}, PARK_TIMEOUT_MS),
|
|
84
|
+
};
|
|
85
|
+
record.timer.unref();
|
|
86
|
+
this.pendingById.set(request_id, record);
|
|
87
|
+
this.pendingByAgent.set(input.agent_id, [...queue, request_id]);
|
|
88
|
+
this.emitQueue(input.agent_id);
|
|
89
|
+
return publicRecord(record);
|
|
90
|
+
}
|
|
91
|
+
pendingForAgent(agentId) {
|
|
92
|
+
return (this.pendingByAgent.get(agentId) ?? [])
|
|
93
|
+
.map((id) => this.pendingById.get(id))
|
|
94
|
+
.filter((r) => Boolean(r))
|
|
95
|
+
.map(publicRecord);
|
|
96
|
+
}
|
|
97
|
+
pendingCount(agentId) {
|
|
98
|
+
return this.pendingByAgent.get(agentId)?.length ?? 0;
|
|
99
|
+
}
|
|
100
|
+
async respond(agentId, requestId, decision, reason) {
|
|
101
|
+
const targetId = requestId ?? this.pendingByAgent.get(agentId)?.[0];
|
|
102
|
+
if (!targetId)
|
|
103
|
+
throw new Error(`agent ${agentId} has no pending permission requests`);
|
|
104
|
+
const record = this.pendingById.get(targetId);
|
|
105
|
+
if (!record || record.agent_id !== agentId) {
|
|
106
|
+
throw new Error(`pending permission ${targetId} not found for agent ${agentId}`);
|
|
107
|
+
}
|
|
108
|
+
if (record.escalate_to_human === true && decision === "allow" && (!reason || reason.trim() === "")) {
|
|
109
|
+
throw new Error("allowing an escalated human permission request requires a non-empty reason");
|
|
110
|
+
}
|
|
111
|
+
return this.finish(record, decision, reason);
|
|
112
|
+
}
|
|
113
|
+
async closeAgent(agentId, reason) {
|
|
114
|
+
const ids = [...(this.pendingByAgent.get(agentId) ?? [])];
|
|
115
|
+
const closed = [];
|
|
116
|
+
for (const id of ids) {
|
|
117
|
+
const record = this.pendingById.get(id);
|
|
118
|
+
if (record)
|
|
119
|
+
closed.push(await this.finish(record, "deny", reason));
|
|
120
|
+
}
|
|
121
|
+
return closed;
|
|
122
|
+
}
|
|
123
|
+
async autoDeny(requestId, reason) {
|
|
124
|
+
const record = this.pendingById.get(requestId);
|
|
125
|
+
if (!record)
|
|
126
|
+
return;
|
|
127
|
+
this.telemetry.park_timeout_auto_denies += 1;
|
|
128
|
+
console.error(`[permissions] auto-deny ${requestId} for agent ${record.agent_id}: ${reason}`);
|
|
129
|
+
await this.finish(record, "deny", reason, "park-timeout");
|
|
130
|
+
}
|
|
131
|
+
async finish(record, decision, reason, autoRule) {
|
|
132
|
+
clearTimeout(record.timer);
|
|
133
|
+
this.pendingById.delete(record.request_id);
|
|
134
|
+
const queue = (this.pendingByAgent.get(record.agent_id) ?? []).filter((id) => id !== record.request_id);
|
|
135
|
+
if (queue.length > 0)
|
|
136
|
+
this.pendingByAgent.set(record.agent_id, queue);
|
|
137
|
+
else
|
|
138
|
+
this.pendingByAgent.delete(record.agent_id);
|
|
139
|
+
record.state = autoRule ? "auto_answered" : "answered";
|
|
140
|
+
record.auto_answer_rule = autoRule;
|
|
141
|
+
record.answered_at = Date.now();
|
|
142
|
+
record.answer = decision;
|
|
143
|
+
record.answer_reason = reason;
|
|
144
|
+
try {
|
|
145
|
+
await record.resolve({
|
|
146
|
+
request_id: record.request_id,
|
|
147
|
+
agent_id: record.agent_id,
|
|
148
|
+
decision,
|
|
149
|
+
reason,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
record.state = "errored";
|
|
154
|
+
record.answer_reason = e instanceof Error ? e.message : String(e);
|
|
155
|
+
}
|
|
156
|
+
this.history.push(publicRecord(record));
|
|
157
|
+
this.emitQueue(record.agent_id);
|
|
158
|
+
return publicRecord(record);
|
|
159
|
+
}
|
|
160
|
+
emitQueue(agentId) {
|
|
161
|
+
const count = this.pendingCount(agentId);
|
|
162
|
+
for (const listener of this.queueListeners)
|
|
163
|
+
listener(agentId, count);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export const pendingPermissionManager = new PendingPermissionManager();
|
|
167
|
+
export function requestPendingPermission(input) {
|
|
168
|
+
if (!input.agentId) {
|
|
169
|
+
return Promise.resolve({
|
|
170
|
+
verdict: "deny",
|
|
171
|
+
reason: "permission request missing agent id; auto-denied fail-closed",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const agentId = input.agentId;
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
pendingPermissionManager.create({
|
|
177
|
+
agent_id: agentId,
|
|
178
|
+
harness_channel: input.harnessChannel,
|
|
179
|
+
tool_name_or_method: input.toolNameOrMethod,
|
|
180
|
+
action: input.action,
|
|
181
|
+
permission_ceiling: input.permissionCeiling,
|
|
182
|
+
escalation: input.escalation,
|
|
183
|
+
irreversible: input.irreversible,
|
|
184
|
+
reason: input.reason,
|
|
185
|
+
suggestions: input.suggestions,
|
|
186
|
+
correlation_id: input.correlationId ?? "unknown",
|
|
187
|
+
resolve: (reply) => resolve({
|
|
188
|
+
verdict: reply.decision,
|
|
189
|
+
reason: reply.reason ?? `permission ${reply.decision}`,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"safe": {
|
|
4
|
+
"tools": ["Read", "Glob", "Grep", "LS"],
|
|
5
|
+
"bashPrefixes": ["ls", "cat", "pwd", "echo", "git status", "git diff", "git log"],
|
|
6
|
+
"webFetchHosts": [
|
|
7
|
+
"docs.anthropic.com",
|
|
8
|
+
"docs.github.com",
|
|
9
|
+
"docs.npmjs.com",
|
|
10
|
+
"github.com",
|
|
11
|
+
"npmjs.com",
|
|
12
|
+
"registry.npmjs.org"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"danger": {
|
|
16
|
+
"toolWritePathPrefixes": ["Edit", "MultiEdit", "Write", "NotebookEdit"],
|
|
17
|
+
"dangerousPathSegments": [".git", ".ssh", ".aws", ".claude", ".codex", ".vscode"],
|
|
18
|
+
"protectedFilenames": ["global-subagent-mcp-config.jsonc", "global-concurrency.jsonc"],
|
|
19
|
+
"bashRegexes": [
|
|
20
|
+
"\\brm\\b[\\s\\S]*(?:\\s-rf\\b|\\s-fr\\b|\\s-r\\s+-f\\b|\\s-f\\s+-r\\b|--force\\b)",
|
|
21
|
+
"\\bgit\\s+push\\b[\\s\\S]*\\s--force(?:-with-lease)?\\b",
|
|
22
|
+
"\\b(?:curl|wget)\\b[\\s\\S]*(?:\\|\\s*(?:sh|bash)\\b)",
|
|
23
|
+
"\\bsudo\\b",
|
|
24
|
+
"\\bchmod\\s+777\\b",
|
|
25
|
+
"\\b(?:npm|pnpm|yarn)\\s+publish\\b",
|
|
26
|
+
"\\b(?:mkfs|diskutil\\s+eraseDisk|format\\.com|format\\s+[A-Za-z]:)"
|
|
27
|
+
],
|
|
28
|
+
"irreversibleBashRegexes": [
|
|
29
|
+
"\\bgit\\s+push\\b[\\s\\S]*\\s--force(?:-with-lease)?\\b",
|
|
30
|
+
"\\bgit\\s+(?:reset\\s+--hard|rebase|filter-branch)\\b",
|
|
31
|
+
"\\b(?:DROP|TRUNCATE)\\b[\\s\\S]*\\b(?:TABLE|DATABASE|SCHEMA)\\b",
|
|
32
|
+
"\\b(?:npm|pnpm|yarn)\\s+publish\\b",
|
|
33
|
+
"\\b(?:aws|gcloud|az)\\b[\\s\\S]*\\bdelete\\b"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import permissionClasses from "./permission-classes.json" with { type: "json" };
|
|
2
|
+
const classes = permissionClasses;
|
|
3
|
+
const safeTools = new Set(classes.safe.tools.map(normalizeToolName));
|
|
4
|
+
const safeBashPrefixes = classes.safe.bashPrefixes.map(tokenizeCommand);
|
|
5
|
+
const safeWebFetchHosts = new Set(classes.safe.webFetchHosts.map((h) => h.toLowerCase()));
|
|
6
|
+
const dangerRegexes = classes.danger.bashRegexes.map((p) => new RegExp(p, "i"));
|
|
7
|
+
const irreversibleRegexes = classes.danger.irreversibleBashRegexes.map((p) => new RegExp(p, "i"));
|
|
8
|
+
const writeTools = new Set(classes.danger.toolWritePathPrefixes.map(normalizeToolName));
|
|
9
|
+
const dangerousPathSegments = new Set(classes.danger.dangerousPathSegments.map((p) => p.toLowerCase()));
|
|
10
|
+
const protectedFilenames = new Set(classes.danger.protectedFilenames.map((p) => p.toLowerCase()));
|
|
11
|
+
const mutatingTools = new Set(["bash", "edit", "multiedit", "write", "notebookedit"]);
|
|
12
|
+
const safeEnvVars = new Set(["NODE_ENV", "GOOS", "LANG"]);
|
|
13
|
+
const maxSubcommandsForSecurityCheck = 50;
|
|
14
|
+
/**
|
|
15
|
+
* Shared trust-boundary decision point for Claude canUseTool and Codex approval
|
|
16
|
+
* payloads. It performs no I/O; callers must provide already-known resolved
|
|
17
|
+
* paths when they want symlink parity.
|
|
18
|
+
*/
|
|
19
|
+
export function verdict(op, rules = {}) {
|
|
20
|
+
const classified = classifyPermissionOp(op);
|
|
21
|
+
if (classified.classification === "danger") {
|
|
22
|
+
return {
|
|
23
|
+
verdict: "deny",
|
|
24
|
+
classification: "danger",
|
|
25
|
+
irreversible: classified.irreversible,
|
|
26
|
+
reason: classified.reason,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const ruleVote = evaluateRuleSet(op, rules);
|
|
30
|
+
if (ruleVote) {
|
|
31
|
+
return {
|
|
32
|
+
verdict: ruleVote.verdict,
|
|
33
|
+
classification: classified.classification,
|
|
34
|
+
irreversible: classified.irreversible,
|
|
35
|
+
matchedRule: ruleVote.rule,
|
|
36
|
+
reason: `${ruleVote.verdict} rule matched: ${ruleVote.rule}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (classified.classification === "safe") {
|
|
40
|
+
return {
|
|
41
|
+
verdict: "allow",
|
|
42
|
+
classification: "safe",
|
|
43
|
+
irreversible: classified.irreversible,
|
|
44
|
+
reason: classified.reason,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
verdict: "ask",
|
|
49
|
+
classification: "neutral",
|
|
50
|
+
irreversible: classified.irreversible,
|
|
51
|
+
reason: "neutral residue defaults to ask",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Applies the launch ceiling after the engine vote. `yolo` bypasses normal
|
|
56
|
+
* gating; adapter-level bypass-immune denials must be enforced by the caller.
|
|
57
|
+
*/
|
|
58
|
+
export function applyPermissionCeiling(engineVote, ceiling) {
|
|
59
|
+
if (ceiling === "yolo")
|
|
60
|
+
return "allow";
|
|
61
|
+
if (ceiling === "manual")
|
|
62
|
+
return minVerdict(engineVote, "ask");
|
|
63
|
+
return minVerdict(engineVote, "allow");
|
|
64
|
+
}
|
|
65
|
+
export function classifyPermissionOp(op) {
|
|
66
|
+
const command = commandText(op);
|
|
67
|
+
const irreversible = Boolean(op.irreversible) || (command !== "" && irreversibleRegexes.some((r) => r.test(command)));
|
|
68
|
+
if (isDangerousPathOp(op)) {
|
|
69
|
+
return {
|
|
70
|
+
classification: "danger",
|
|
71
|
+
irreversible,
|
|
72
|
+
reason: "dangerous or protected path",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (normalizeToolName(op.tool) === "bash") {
|
|
76
|
+
if (command === "")
|
|
77
|
+
return { classification: "neutral", irreversible, reason: "empty bash command" };
|
|
78
|
+
if (countSubcommands(command) > maxSubcommandsForSecurityCheck) {
|
|
79
|
+
return { classification: "neutral", irreversible, reason: "too many subcommands to classify safely" };
|
|
80
|
+
}
|
|
81
|
+
if (dangerRegexes.some((r) => r.test(command))) {
|
|
82
|
+
return { classification: "danger", irreversible, reason: "dangerous bash pattern" };
|
|
83
|
+
}
|
|
84
|
+
if (countSubcommands(command) > 1) {
|
|
85
|
+
return { classification: "neutral", irreversible, reason: "compound bash command requires review" };
|
|
86
|
+
}
|
|
87
|
+
if (isSafeBashCommand(command)) {
|
|
88
|
+
return { classification: "safe", irreversible, reason: "safe bash prefix" };
|
|
89
|
+
}
|
|
90
|
+
return { classification: "neutral", irreversible, reason: "unmatched bash command" };
|
|
91
|
+
}
|
|
92
|
+
if (normalizeToolName(op.tool) === "webfetch" && isPreapprovedWebFetch(op)) {
|
|
93
|
+
return { classification: "safe", irreversible, reason: "preapproved WebFetch host" };
|
|
94
|
+
}
|
|
95
|
+
if (safeTools.has(normalizeToolName(op.tool))) {
|
|
96
|
+
return { classification: "safe", irreversible, reason: "safe read-only tool" };
|
|
97
|
+
}
|
|
98
|
+
return { classification: "neutral", irreversible, reason: "unmatched tool" };
|
|
99
|
+
}
|
|
100
|
+
export function evaluateRuleSet(op, rules) {
|
|
101
|
+
return (firstRuleMatch(op, "deny", rules.deny) ??
|
|
102
|
+
firstRuleMatch(op, "ask", rules.ask) ??
|
|
103
|
+
firstRuleMatch(op, "allow", rules.allow));
|
|
104
|
+
}
|
|
105
|
+
export function parsePermissionRule(rule) {
|
|
106
|
+
const firstParen = rule.indexOf("(");
|
|
107
|
+
const lastParen = rule.lastIndexOf(")");
|
|
108
|
+
if (firstParen < 0 || lastParen <= firstParen) {
|
|
109
|
+
return { tool: rule.trim(), content: null };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
tool: rule.slice(0, firstParen).trim(),
|
|
113
|
+
content: rule.slice(firstParen + 1, lastParen),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function firstRuleMatch(op, vote, rules) {
|
|
117
|
+
for (const rule of rules ?? []) {
|
|
118
|
+
if (matchesRule(op, rule))
|
|
119
|
+
return { verdict: vote, rule };
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function matchesRule(op, rawRule) {
|
|
124
|
+
const rule = parsePermissionRule(rawRule);
|
|
125
|
+
if (rule.tool === "")
|
|
126
|
+
return false;
|
|
127
|
+
const opTool = normalizeToolName(op.tool);
|
|
128
|
+
const ruleTool = normalizeToolName(rule.tool);
|
|
129
|
+
if (ruleTool !== "*" && ruleTool !== opTool)
|
|
130
|
+
return false;
|
|
131
|
+
if (rule.content === null || rule.content.trim() === "" || rule.content.trim() === "*")
|
|
132
|
+
return true;
|
|
133
|
+
if (opTool === "bash")
|
|
134
|
+
return matchesBashRule(commandText(op), rule.content);
|
|
135
|
+
if (opTool === "webfetch")
|
|
136
|
+
return matchesNetworkRule(op, rule.content);
|
|
137
|
+
if (writeTools.has(opTool) || op.paths?.length || op.resolvedPaths?.length) {
|
|
138
|
+
return matchesAnyPath(op, rule.content);
|
|
139
|
+
}
|
|
140
|
+
return wildcardMatch(rule.content, JSON.stringify(op));
|
|
141
|
+
}
|
|
142
|
+
function matchesBashRule(command, pattern) {
|
|
143
|
+
if (command === "")
|
|
144
|
+
return false;
|
|
145
|
+
const stripped = stripEnvPrefix(command, true);
|
|
146
|
+
const positive = pattern.trim();
|
|
147
|
+
const excludes = positive.split(/\s+&&\s+|\s*;\s*|\s+\|\|\s+/).filter((p) => p.trim() !== "");
|
|
148
|
+
if (excludes.length > 1 && positive === command)
|
|
149
|
+
return false;
|
|
150
|
+
if (positive.endsWith(":*")) {
|
|
151
|
+
const prefix = positive.slice(0, -2);
|
|
152
|
+
return hasWordBoundaryPrefix(stripped, prefix);
|
|
153
|
+
}
|
|
154
|
+
return wildcardMatch(positive, stripped) || hasWordBoundaryPrefix(stripped, positive);
|
|
155
|
+
}
|
|
156
|
+
function matchesNetworkRule(op, pattern) {
|
|
157
|
+
const needle = pattern.startsWith("domain:") ? pattern.slice("domain:".length) : pattern;
|
|
158
|
+
return networkHosts(op).some((host) => host === needle.toLowerCase() || host.endsWith(`.${needle.toLowerCase()}`));
|
|
159
|
+
}
|
|
160
|
+
function matchesAnyPath(op, pattern) {
|
|
161
|
+
return allPaths(op).some((p) => {
|
|
162
|
+
const normalized = normalizePathForMatch(p);
|
|
163
|
+
const rule = normalizePathForMatch(pattern);
|
|
164
|
+
return normalized === rule || normalized.startsWith(`${rule}/`) || wildcardMatch(rule, normalized);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function isDangerousPathOp(op) {
|
|
168
|
+
if (!writeTools.has(normalizeToolName(op.tool)))
|
|
169
|
+
return false;
|
|
170
|
+
return allPaths(op).some((p) => {
|
|
171
|
+
const normalized = normalizePathForMatch(p);
|
|
172
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
173
|
+
const last = parts[parts.length - 1]?.toLowerCase() ?? "";
|
|
174
|
+
return parts.some((part) => dangerousPathSegments.has(part.toLowerCase())) || protectedFilenames.has(last);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function isSafeBashCommand(command) {
|
|
178
|
+
const stripped = stripEnvPrefix(command, false);
|
|
179
|
+
const tokens = tokenizeCommand(stripped);
|
|
180
|
+
if (tokens.length === 0)
|
|
181
|
+
return false;
|
|
182
|
+
return safeBashPrefixes.some((prefix) => startsWithTokens(tokens, prefix));
|
|
183
|
+
}
|
|
184
|
+
function isPreapprovedWebFetch(op) {
|
|
185
|
+
return networkHosts(op).some((host) => safeWebFetchHosts.has(host));
|
|
186
|
+
}
|
|
187
|
+
function commandText(op) {
|
|
188
|
+
if (op.command !== undefined)
|
|
189
|
+
return op.command;
|
|
190
|
+
if (op.argv && op.argv.length > 0)
|
|
191
|
+
return op.argv.join(" ");
|
|
192
|
+
return "";
|
|
193
|
+
}
|
|
194
|
+
function normalizeToolName(tool) {
|
|
195
|
+
const tail = tool.split("__").pop() ?? tool;
|
|
196
|
+
return tail.replace(/[^A-Za-z]/g, "").toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
function stripEnvPrefix(command, denyMode) {
|
|
199
|
+
let out = command.trim();
|
|
200
|
+
while (true) {
|
|
201
|
+
const match = out.match(/^([A-Za-z_][A-Za-z0-9_]*)=(?:"[^"]*"|'[^']*'|\S+)\s+([\s\S]*)$/);
|
|
202
|
+
if (!match)
|
|
203
|
+
return out;
|
|
204
|
+
if (!denyMode && !safeEnvVars.has(match[1]))
|
|
205
|
+
return out;
|
|
206
|
+
out = match[2].trim();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function tokenizeCommand(command) {
|
|
210
|
+
return command.trim().split(/\s+/).filter(Boolean);
|
|
211
|
+
}
|
|
212
|
+
function startsWithTokens(tokens, prefix) {
|
|
213
|
+
if (prefix.length > tokens.length)
|
|
214
|
+
return false;
|
|
215
|
+
return prefix.every((part, i) => tokens[i] === part || tokens[i] === `${part}:`);
|
|
216
|
+
}
|
|
217
|
+
function hasWordBoundaryPrefix(command, prefix) {
|
|
218
|
+
if (!command.startsWith(prefix))
|
|
219
|
+
return false;
|
|
220
|
+
const next = command[prefix.length];
|
|
221
|
+
return next === undefined || /[\s:;|&]/.test(next);
|
|
222
|
+
}
|
|
223
|
+
function countSubcommands(command) {
|
|
224
|
+
return command.split(/&&|\|\||;|\|/).length;
|
|
225
|
+
}
|
|
226
|
+
function allPaths(op) {
|
|
227
|
+
return [...(op.paths ?? []), ...(op.resolvedPaths ?? [])];
|
|
228
|
+
}
|
|
229
|
+
function networkHosts(op) {
|
|
230
|
+
const hosts = [];
|
|
231
|
+
for (const target of op.network ?? []) {
|
|
232
|
+
if (target.host)
|
|
233
|
+
hosts.push(target.host.toLowerCase());
|
|
234
|
+
if (target.url) {
|
|
235
|
+
try {
|
|
236
|
+
hosts.push(new URL(target.url).hostname.toLowerCase());
|
|
237
|
+
}
|
|
238
|
+
catch { }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return hosts;
|
|
242
|
+
}
|
|
243
|
+
function normalizePathForMatch(path) {
|
|
244
|
+
return path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "").toLowerCase();
|
|
245
|
+
}
|
|
246
|
+
function wildcardMatch(pattern, value) {
|
|
247
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*");
|
|
248
|
+
return new RegExp(`^${escaped}$`, "i").test(value);
|
|
249
|
+
}
|
|
250
|
+
function minVerdict(a, b) {
|
|
251
|
+
const rank = { deny: 0, ask: 1, allow: 2 };
|
|
252
|
+
return rank[a] <= rank[b] ? a : b;
|
|
253
|
+
}
|