Skip to content

Commit b579e8a

Browse files
committed
feat: granular session-scoped allow rules
Add allowForSessionGranular to SecurityRule, enabling per-argument allow overrides scoped to a session key prefix. Lets users approve a specific file path, command, or URL for a cron job session without blanket-allowing the entire module.method. - types.ts: add allowForSessionGranular to SecurityRule - Interceptor.ts: add extractPrimaryArg(), check granular entries via glob; add reloadPolicy() - config.ts: add Session/Memory/Agent default rules; FileSystem.delete DENY -> ASK - test/granular-allow.test.mjs: full test coverage
1 parent 3084570 commit b579e8a

4 files changed

Lines changed: 330 additions & 24 deletions

File tree

src/config.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ export const DEFAULT_POLICY: SecurityPolicy = {
2020
description: 'Modification of files requires approval',
2121
},
2222
delete: {
23-
action: 'DENY',
24-
description: 'Deletion is strictly prohibited',
23+
action: 'ASK',
24+
description: 'File deletion requires approval',
2525
},
2626
},
2727
Shell: {
@@ -76,5 +76,26 @@ export const DEFAULT_POLICY: SecurityPolicy = {
7676
description: 'HTTP request may leak data',
7777
},
7878
},
79+
// Internal session/agent operations — reading state is safe, never needs approval.
80+
Session: {
81+
history: { action: 'ALLOW', description: 'Reading session history is safe' },
82+
list: { action: 'ALLOW', description: 'Listing sessions is safe' },
83+
status: { action: 'ALLOW', description: 'Reading session status is safe' },
84+
yield: { action: 'ALLOW', description: 'Internal async yield — no user action needed' },
85+
create: { action: 'ASK', description: 'Creating a new session' },
86+
destroy: { action: 'ASK', description: 'Destroying a session requires approval' },
87+
},
88+
Memory: {
89+
read: { action: 'ALLOW', description: 'Reading memory is safe' },
90+
list: { action: 'ALLOW', description: 'Listing memory entries is safe' },
91+
update: { action: 'ASK', description: 'Writing to memory requires approval' },
92+
delete: { action: 'ASK', description: 'Deleting memory entries requires approval' },
93+
},
94+
Agent: {
95+
status: { action: 'ALLOW', description: 'Reading agent status is safe' },
96+
list: { action: 'ALLOW', description: 'Listing agents is safe' },
97+
spawn: { action: 'ASK', description: 'Spawning a sub-agent requires approval' },
98+
stop: { action: 'ASK', description: 'Stopping an agent requires approval' },
99+
},
79100
},
80101
};

src/core/Interceptor.ts

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,25 @@ function matchesGlob(pattern: string, str: string): boolean {
4848
return new RegExp(`^${regex}$`).test(norm);
4949
}
5050

51+
/**
52+
* Extract the primary arg value for a tool call, used for granular session allow matching.
53+
* Returns the raw string value (not resolved) so globs can match commands and URLs too.
54+
*/
55+
function extractPrimaryArg(moduleName: string, params: Record<string, unknown>): string | null {
56+
if (moduleName === 'Browser' || moduleName === 'Network') {
57+
const v = params.url ?? params.src ?? null;
58+
return v != null ? String(v) : null;
59+
}
60+
if (moduleName === 'Shell') {
61+
const v = params.command ?? params.cmd ?? null;
62+
return v != null ? String(v) : null;
63+
}
64+
const raw = params.path ?? params.file_path ?? params.filePath ?? params.target ?? null;
65+
if (raw != null) return path.resolve(expandTilde(String(raw)));
66+
const first = Object.values(params)[0];
67+
return first != null ? String(first) : null;
68+
}
69+
5170
/**
5271
* Extract the path or URL that a tool call is targeting, for path-policy evaluation.
5372
* Returns null if no target can be determined (policy check is skipped).
@@ -124,6 +143,15 @@ export class Interceptor {
124143
this.logEnabled = logEnabled;
125144
}
126145

146+
/** Hot-reload the policy without restarting the gateway. */
147+
reloadPolicy(policy: SecurityPolicy): void {
148+
this.policy = policy;
149+
logger.info('Interceptor: policy hot-reloaded', {
150+
modules: Object.keys(policy.modules),
151+
defaultAction: policy.defaultAction,
152+
});
153+
}
154+
127155
/**
128156
* Evaluate the security policy for a tool call.
129157
*/
@@ -134,7 +162,7 @@ export class Interceptor {
134162
sessionKey?: string,
135163
intervention?: InterventionMetadata
136164
): Promise<void> {
137-
const baseRule = this.lookupRule(moduleName, methodName);
165+
const baseRule = this.lookupRule(moduleName, methodName, sessionKey, args);
138166
// Path-policy check runs before intervention so a denied path is never
139167
// upgradeable to ASK — it's a hard DENY.
140168
const originalRule = applyPathPolicy(baseRule, moduleName, args);
@@ -177,14 +205,13 @@ export class Interceptor {
177205
);
178206

179207
throw new Error(
180-
`[ClawReins:APPROVAL_REQUIRED] ${moduleName}.${methodName}() is blocked pending human approval. ` +
181-
`Risk: ${detail}\n` +
208+
`Action not permitted: ${moduleName}.${methodName}() requires human authorization.\n` +
182209
instructions
183210
);
184211
}
185212

186213
throw new Error(
187-
`ClawReins Security Violation: ${moduleName}.${methodName}() was DENIED. ${detail}`
214+
`Action not permitted: ${moduleName}.${methodName}() was denied. ${detail}`
188215
);
189216
}
190217
}
@@ -276,26 +303,41 @@ export class Interceptor {
276303
if (strict) {
277304
return (
278305
`${screenshotInstruction}` +
279-
`⚠️ HIGH-RISK action requires explicit human confirmation.\n` +
280306
`Action: ${summary}\n` +
281-
`An out-of-band approval notification has been sent to the human.\n` +
282-
`WAIT — do NOT retry this tool. Do NOT attempt to self-approve.`
307+
`WAIT — do NOT retry this tool. Do NOT attempt to proceed without explicit authorization.`
283308
);
284309
}
285310

286311
return (
287312
`${screenshotInstruction}` +
288313
`Action: ${summary}\n` +
289-
`An approval request has been sent to the human out-of-band.\n` +
290-
`WAIT for their response before retrying. Do NOT attempt to self-approve.`
314+
`WAIT for authorization before retrying. Do NOT attempt to proceed independently.`
291315
);
292316
}
293317

294-
private lookupRule(moduleName: string, methodName: string): SecurityRule {
318+
private lookupRule(moduleName: string, methodName: string, sessionKey?: string, args?: unknown[]): SecurityRule {
295319
const moduleRules = this.policy.modules[moduleName];
320+
const rule = moduleRules?.[methodName];
321+
322+
if (rule) {
323+
// Blanket session allow — matches all calls to this module.method.
324+
if (sessionKey && rule.allowForSessionKeys?.some(prefix => sessionKey.startsWith(prefix))) {
325+
return { action: 'ALLOW', description: `Allowed for session ${sessionKey}` };
326+
}
296327

297-
if (moduleRules && moduleRules[methodName]) {
298-
return moduleRules[methodName];
328+
// Granular session+arg allow — matches by session prefix AND primary arg glob.
329+
if (sessionKey && args && rule.allowForSessionGranular?.length) {
330+
const params = (args[0] as Record<string, unknown>) ?? {};
331+
const primaryArg = extractPrimaryArg(moduleName, params);
332+
if (primaryArg !== null) {
333+
const matched = rule.allowForSessionGranular.some(
334+
entry => sessionKey.startsWith(entry.sessionKeyPrefix) && matchesGlob(entry.argGlob, primaryArg)
335+
);
336+
if (matched) return { action: 'ALLOW', description: `Allowed for session ${sessionKey} with arg pattern` };
337+
}
338+
}
339+
340+
return rule;
299341
}
300342

301343
return {

src/types.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ export interface SecurityRule {
2929
* Examples: ["/etc/GLOB", "~/.ssh/GLOB", "GLOB/.env"] (GLOB = **)
3030
*/
3131
denyPaths?: string[];
32+
/**
33+
* Session key prefixes for which this rule is automatically ALLOW regardless of action.
34+
* Blanket: allows ALL calls to this module.method for matching sessions.
35+
*/
36+
allowForSessionKeys?: string[];
37+
/**
38+
* Granular per-session allows. Each entry requires both a session key prefix match AND a
39+
* glob match on the primary arg (path, command, url). More precise than allowForSessionKeys.
40+
* Example: [{sessionKeyPrefix:"agent:main:cron:abc", argGlob:"/tmp/reports/**"}]
41+
*/
42+
allowForSessionGranular?: Array<{
43+
sessionKeyPrefix: string;
44+
argGlob: string;
45+
}>;
3246
}
3347

3448
/**
@@ -66,16 +80,6 @@ export interface InterventionMetadata {
6680
recommendScreenshotReview?: boolean;
6781
/** Current cooldown escalation level (0=normal, 1=heightened, 2=restricted). */
6882
cooldownLevel?: number;
69-
/** Destructive-intercept severity for UI and audit context. */
70-
destructiveSeverity?: 'HIGH' | 'CATASTROPHIC';
71-
/** Reasons from destructive classifier. */
72-
destructiveReasons?: string[];
73-
/** Optional bulk count identified by destructive classifier. */
74-
destructiveBulkCount?: number;
75-
/** Optional user-facing target (mailbox/path/host). */
76-
destructiveTarget?: string;
77-
/** Require channel approvals to come via clawreins_respond (fail-secure if unavailable). */
78-
requiresRespondToolApproval?: boolean;
7983
}
8084

8185
/**

0 commit comments

Comments
 (0)