@safefence/openclaw-guardrails 0.3.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 +182 -0
- package/dist/core/approval-store.d.ts +29 -0
- package/dist/core/approval-store.js +124 -0
- package/dist/core/approval.d.ts +18 -0
- package/dist/core/approval.js +129 -0
- package/dist/core/authorization.d.ts +7 -0
- package/dist/core/authorization.js +114 -0
- package/dist/core/budget-store.d.ts +6 -0
- package/dist/core/budget-store.js +33 -0
- package/dist/core/command-parse.d.ts +11 -0
- package/dist/core/command-parse.js +67 -0
- package/dist/core/detectors/budget-detector.d.ts +4 -0
- package/dist/core/detectors/budget-detector.js +35 -0
- package/dist/core/detectors/command-policy-detector.d.ts +3 -0
- package/dist/core/detectors/command-policy-detector.js +74 -0
- package/dist/core/detectors/index.d.ts +11 -0
- package/dist/core/detectors/index.js +11 -0
- package/dist/core/detectors/input-intent-detector.d.ts +3 -0
- package/dist/core/detectors/input-intent-detector.js +55 -0
- package/dist/core/detectors/network-egress-detector.d.ts +3 -0
- package/dist/core/detectors/network-egress-detector.js +68 -0
- package/dist/core/detectors/output-safety-detector.d.ts +2 -0
- package/dist/core/detectors/output-safety-detector.js +36 -0
- package/dist/core/detectors/owner-approval-detector.d.ts +4 -0
- package/dist/core/detectors/owner-approval-detector.js +62 -0
- package/dist/core/detectors/path-canonical-detector.d.ts +3 -0
- package/dist/core/detectors/path-canonical-detector.js +46 -0
- package/dist/core/detectors/principal-authz-detector.d.ts +3 -0
- package/dist/core/detectors/principal-authz-detector.js +14 -0
- package/dist/core/detectors/provenance-detector.d.ts +3 -0
- package/dist/core/detectors/provenance-detector.js +8 -0
- package/dist/core/detectors/restricted-info-detector.d.ts +2 -0
- package/dist/core/detectors/restricted-info-detector.js +50 -0
- package/dist/core/detectors/sensitive-data-detector.d.ts +2 -0
- package/dist/core/detectors/sensitive-data-detector.js +55 -0
- package/dist/core/detectors/types.d.ts +20 -0
- package/dist/core/detectors/types.js +1 -0
- package/dist/core/engine.d.ts +12 -0
- package/dist/core/engine.js +123 -0
- package/dist/core/event-utils.d.ts +10 -0
- package/dist/core/event-utils.js +105 -0
- package/dist/core/identity.d.ts +8 -0
- package/dist/core/identity.js +102 -0
- package/dist/core/network-guard.d.ts +5 -0
- package/dist/core/network-guard.js +134 -0
- package/dist/core/normalize.d.ts +2 -0
- package/dist/core/normalize.js +60 -0
- package/dist/core/path-canonical.d.ts +9 -0
- package/dist/core/path-canonical.js +69 -0
- package/dist/core/reason-codes.d.ts +43 -0
- package/dist/core/reason-codes.js +42 -0
- package/dist/core/retrieval-trust.d.ts +2 -0
- package/dist/core/retrieval-trust.js +65 -0
- package/dist/core/scoring.d.ts +2 -0
- package/dist/core/scoring.js +18 -0
- package/dist/core/supply-chain.d.ts +2 -0
- package/dist/core/supply-chain.js +78 -0
- package/dist/core/types.d.ts +149 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/plugin/openclaw-adapter.d.ts +41 -0
- package/dist/plugin/openclaw-adapter.js +313 -0
- package/dist/plugin/openclaw-extension.d.ts +14 -0
- package/dist/plugin/openclaw-extension.js +62 -0
- package/dist/redaction/redact.d.ts +6 -0
- package/dist/redaction/redact.js +31 -0
- package/dist/rules/default-policy.d.ts +3 -0
- package/dist/rules/default-policy.js +200 -0
- package/dist/rules/patterns.d.ts +8 -0
- package/dist/rules/patterns.js +64 -0
- package/openclaw.plugin.json +147 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# OpenClaw Guardrails v3
|
|
2
|
+
|
|
3
|
+
Native TypeScript security kernel for OpenClaw (`>=2026.2.25`) with deterministic local enforcement, principal-aware authorization, and owner approval for group/multi-user safety.
|
|
4
|
+
|
|
5
|
+
## Repository Context
|
|
6
|
+
|
|
7
|
+
- Root project overview: [`../../README.md`](../../README.md)
|
|
8
|
+
- Research and threat analysis: [`../../docs/openclaw-llm-security-research.md`](../../docs/openclaw-llm-security-research.md)
|
|
9
|
+
- OWASP LLM coverage mapping: see the research doc above.
|
|
10
|
+
|
|
11
|
+
## Core Model
|
|
12
|
+
|
|
13
|
+
- One engine path for all phases (`GuardrailsEngine`).
|
|
14
|
+
- Declarative policy + deterministic reason codes.
|
|
15
|
+
- Monotonic precedence: `DENY > REDACT > ALLOW`.
|
|
16
|
+
- No runtime dependency on remote inference or policy services.
|
|
17
|
+
- Audit mode still applies redaction by default.
|
|
18
|
+
|
|
19
|
+
## v3 Security Features
|
|
20
|
+
|
|
21
|
+
- Principal-aware identity model (`owner/admin/member/unknown`).
|
|
22
|
+
- **Anti-spoofing**: privileged roles (`owner`/`admin`) are derived exclusively from `principal.ownerIds`/`adminIds` in config — caller-supplied `metadata.role` values of `"owner"` or `"admin"` are downgraded to `"member"`.
|
|
23
|
+
- Group-aware authorization (mention-gating + role tool policy).
|
|
24
|
+
- One-time owner approval challenges with TTL, action digest binding, anti-replay, and requester identity binding.
|
|
25
|
+
- Optional persistent approval store (`approval.storagePath`) with storage path validation (must be within `workspaceRoot`) and expired record pruning.
|
|
26
|
+
- **Reason code sanitization**: sensitive internal reason codes (e.g. `PROMPT_INJECTION`) are replaced with `CONTENT_POLICY_VIOLATION` in client-facing output to prevent detection fingerprinting.
|
|
27
|
+
- Principal-partitioned budgets (`agent + principal + conversation`).
|
|
28
|
+
- Restricted-info redaction for non-privileged group principals.
|
|
29
|
+
- Rollout controls (`stage_a_audit`, `stage_b_high_risk_enforce`, `stage_c_full_enforce`).
|
|
30
|
+
- Monitoring snapshot with false-positive threshold signaling.
|
|
31
|
+
|
|
32
|
+
## Architecture
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/
|
|
36
|
+
├── index.ts # Public exports
|
|
37
|
+
├── core/
|
|
38
|
+
│ ├── engine.ts # Ordered detector pipeline + final decisioning
|
|
39
|
+
│ ├── identity.ts # Principal normalization + anti-spoofing
|
|
40
|
+
│ ├── authorization.ts # Role/channel/data-class policy evaluation
|
|
41
|
+
│ ├── approval.ts # Owner approval broker
|
|
42
|
+
│ ├── approval-store.ts # Persistent approval state + pruning
|
|
43
|
+
│ ├── budget-store.ts # Per-principal budget tracking
|
|
44
|
+
│ ├── normalize.ts # Event normalization
|
|
45
|
+
│ ├── event-utils.ts # Guard event helpers
|
|
46
|
+
│ ├── scoring.ts # Risk score aggregation
|
|
47
|
+
│ ├── reason-codes.ts # Canonical reason code constants
|
|
48
|
+
│ ├── types.ts # Core type definitions
|
|
49
|
+
│ ├── command-parse.ts # Command string parsing
|
|
50
|
+
│ ├── network-guard.ts # Network host/URL validation
|
|
51
|
+
│ ├── path-canonical.ts # Path canonicalization + symlink checks
|
|
52
|
+
│ ├── retrieval-trust.ts # Retrieval trust level evaluation
|
|
53
|
+
│ ├── supply-chain.ts # Skill source + hash policy
|
|
54
|
+
│ └── detectors/ # Security detector modules
|
|
55
|
+
├── plugin/
|
|
56
|
+
│ ├── openclaw-adapter.ts # OpenClaw hook adapter + summary telemetry
|
|
57
|
+
│ └── openclaw-extension.ts # Plugin entry point (registerOpenClawGuardrails)
|
|
58
|
+
├── redaction/
|
|
59
|
+
│ └── redact.ts # Secret/PII redaction engine
|
|
60
|
+
└── rules/
|
|
61
|
+
├── default-policy.ts # Default config factory + merge
|
|
62
|
+
└── patterns.ts # Detection pattern definitions
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Owner Approval Flow
|
|
66
|
+
|
|
67
|
+
1. Member in group requests a restricted action.
|
|
68
|
+
2. Engine returns `DENY` with `OWNER_APPROVAL_REQUIRED` and `approvalChallenge`.
|
|
69
|
+
3. Owner/admin approves out-of-band and issues one-time token.
|
|
70
|
+
4. Caller retries with `metadata.approval.token` (and optionally `requestId`).
|
|
71
|
+
5. Engine verifies TTL, digest, conversation binding, requester identity binding, requestId (when provided), and replay status.
|
|
72
|
+
6. Valid token allows reevaluation and execution.
|
|
73
|
+
|
|
74
|
+
Approval works across all channel types (DM, group, thread), not just groups — group context merely triggers the initial challenge for restricted actions.
|
|
75
|
+
|
|
76
|
+
## Install in OpenClaw
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
openclaw plugins install @safefence/openclaw-guardrails
|
|
80
|
+
openclaw plugins list
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Configure `openclaw.config.ts`
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { defineConfig } from "openclaw/config";
|
|
87
|
+
|
|
88
|
+
export default defineConfig({
|
|
89
|
+
plugins: {
|
|
90
|
+
entries: {
|
|
91
|
+
"openclaw-guardrails": {
|
|
92
|
+
enabled: true,
|
|
93
|
+
config: {
|
|
94
|
+
workspaceRoot: "/workspace/project",
|
|
95
|
+
mode: "enforce",
|
|
96
|
+
failClosed: true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
After changing plugin install/config, restart the OpenClaw service or gateway process so hook registration is reloaded.
|
|
105
|
+
|
|
106
|
+
## Usage
|
|
107
|
+
|
|
108
|
+
Three main entry points:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// 1. Plugin factory — returns an OpenClaw-compatible plugin with hook handlers
|
|
112
|
+
import { createOpenClawGuardrailsPlugin } from "@safefence/openclaw-guardrails";
|
|
113
|
+
|
|
114
|
+
const plugin = createOpenClawGuardrailsPlugin({
|
|
115
|
+
workspaceRoot: "/workspace/project",
|
|
116
|
+
mode: "enforce",
|
|
117
|
+
failClosed: true
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Out-of-band owner approval
|
|
121
|
+
const token = plugin.approveRequest(requestId, "owner-user-id", "owner");
|
|
122
|
+
|
|
123
|
+
// 2. OpenClaw extension entry — auto-registers all hooks from plugin config
|
|
124
|
+
import { registerOpenClawGuardrails } from "@safefence/openclaw-guardrails";
|
|
125
|
+
registerOpenClawGuardrails(api);
|
|
126
|
+
|
|
127
|
+
// 3. Engine directly — for custom integrations outside OpenClaw
|
|
128
|
+
import { GuardrailsEngine } from "@safefence/openclaw-guardrails";
|
|
129
|
+
const engine = new GuardrailsEngine(config);
|
|
130
|
+
const decision = await engine.evaluate(event);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Exported types**: `ApproverRole`, `ChannelType`, `DataClass`, `Decision`, `PrincipalContext`, `PrincipalRole`, `RolloutStage`, `GuardDecision`, `GuardEvent`, `GuardrailsConfig`, `Phase`.
|
|
134
|
+
|
|
135
|
+
**Exported constants**: `REASON_CODES`, `UNKNOWN_SENDER`, `UNKNOWN_CONVERSATION`.
|
|
136
|
+
|
|
137
|
+
**Config helpers**: `createDefaultConfig()`, `mergeConfig(base, overrides)`.
|
|
138
|
+
|
|
139
|
+
## Config Example (Minimal Overrides)
|
|
140
|
+
|
|
141
|
+
Most config has secure defaults. Override only what you need:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const plugin = createOpenClawGuardrailsPlugin({
|
|
145
|
+
workspaceRoot: "/workspace/project",
|
|
146
|
+
principal: {
|
|
147
|
+
ownerIds: ["owner-user-id"],
|
|
148
|
+
adminIds: ["admin-user-id"]
|
|
149
|
+
},
|
|
150
|
+
approval: {
|
|
151
|
+
enabled: true,
|
|
152
|
+
storagePath: "/workspace/project/.openclaw/approval-store.json"
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
See the [research doc](../../docs/openclaw-llm-security-research.md) for a full config reference with all fields.
|
|
158
|
+
|
|
159
|
+
## Migration (v2 -> v3)
|
|
160
|
+
|
|
161
|
+
1. Add `principal`, `authorization`, `approval`, and `tenancy` blocks.
|
|
162
|
+
2. Pass sender/channel metadata in hook contexts (`senderId`, `conversationId`, `channelType`, `mentionedAgent`).
|
|
163
|
+
3. Integrate owner approval handling via `approvalChallenge.requestId` + `plugin.approveRequest(...)`.
|
|
164
|
+
4. Keep secure defaults unless you have a validated exception.
|
|
165
|
+
5. Use `rollout.stage` for staged deployment and monitor `metadata.guardrailsMonitoring`.
|
|
166
|
+
6. **Breaking**: callers can no longer self-assign privileged roles (`owner`/`admin`) via `metadata.role`. Privileged roles are now derived exclusively from `principal.ownerIds`/`adminIds` in config. Any caller-supplied `"owner"` or `"admin"` role is downgraded to `"member"`.
|
|
167
|
+
|
|
168
|
+
## Limitations
|
|
169
|
+
|
|
170
|
+
- Deterministic patterns are not a full semantic jailbreak solution.
|
|
171
|
+
- Persistent approval store prunes expired records on write; replayed tokens are still caught within the TTL window. Approval tokens survive restarts when `storagePath` is configured.
|
|
172
|
+
- Retrieval trust still depends on upstream metadata quality.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
cd packages/openclaw-guardrails
|
|
178
|
+
npm install
|
|
179
|
+
npm test
|
|
180
|
+
npm run test:coverage
|
|
181
|
+
npm run build
|
|
182
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ApproverRole } from "./types.js";
|
|
2
|
+
export interface ApprovalRecord {
|
|
3
|
+
requestId: string;
|
|
4
|
+
actionDigest: string;
|
|
5
|
+
requesterId: string;
|
|
6
|
+
conversationId: string;
|
|
7
|
+
requiredRole: ApproverRole;
|
|
8
|
+
reason: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
token?: string;
|
|
12
|
+
approvedBy?: string;
|
|
13
|
+
approverIds: string[];
|
|
14
|
+
usedAt?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class ApprovalStore {
|
|
17
|
+
private readonly byRequestId;
|
|
18
|
+
private readonly requestIdByToken;
|
|
19
|
+
private readonly storagePath?;
|
|
20
|
+
constructor(storagePath?: string, allowedRoot?: string);
|
|
21
|
+
save(record: ApprovalRecord): void;
|
|
22
|
+
getByRequestId(requestId: string): ApprovalRecord | undefined;
|
|
23
|
+
getByToken(token: string): ApprovalRecord | undefined;
|
|
24
|
+
setToken(requestId: string, token: string, approvedBy: string): ApprovalRecord | undefined;
|
|
25
|
+
markUsed(requestId: string, usedAt: number): ApprovalRecord | undefined;
|
|
26
|
+
private pruneExpired;
|
|
27
|
+
private loadFromDisk;
|
|
28
|
+
private flushToDisk;
|
|
29
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class ApprovalStore {
|
|
4
|
+
byRequestId = new Map();
|
|
5
|
+
requestIdByToken = new Map();
|
|
6
|
+
storagePath;
|
|
7
|
+
constructor(storagePath, allowedRoot) {
|
|
8
|
+
if (storagePath && allowedRoot) {
|
|
9
|
+
const resolved = path.resolve(storagePath);
|
|
10
|
+
const resolvedRoot = path.resolve(allowedRoot);
|
|
11
|
+
if (!resolved.startsWith(resolvedRoot + path.sep) && resolved !== resolvedRoot) {
|
|
12
|
+
throw new Error(`storagePath must be within ${resolvedRoot}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
this.storagePath = storagePath;
|
|
16
|
+
this.loadFromDisk();
|
|
17
|
+
}
|
|
18
|
+
save(record) {
|
|
19
|
+
this.byRequestId.set(record.requestId, record);
|
|
20
|
+
if (record.token) {
|
|
21
|
+
this.requestIdByToken.set(record.token, record.requestId);
|
|
22
|
+
}
|
|
23
|
+
this.flushToDisk();
|
|
24
|
+
}
|
|
25
|
+
getByRequestId(requestId) {
|
|
26
|
+
return this.byRequestId.get(requestId);
|
|
27
|
+
}
|
|
28
|
+
getByToken(token) {
|
|
29
|
+
const requestId = this.requestIdByToken.get(token);
|
|
30
|
+
if (!requestId) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return this.byRequestId.get(requestId);
|
|
34
|
+
}
|
|
35
|
+
setToken(requestId, token, approvedBy) {
|
|
36
|
+
const record = this.byRequestId.get(requestId);
|
|
37
|
+
if (!record) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (record.token) {
|
|
41
|
+
this.requestIdByToken.delete(record.token);
|
|
42
|
+
}
|
|
43
|
+
const updated = {
|
|
44
|
+
...record,
|
|
45
|
+
token,
|
|
46
|
+
approvedBy
|
|
47
|
+
};
|
|
48
|
+
this.byRequestId.set(requestId, updated);
|
|
49
|
+
this.requestIdByToken.set(token, requestId);
|
|
50
|
+
this.flushToDisk();
|
|
51
|
+
return updated;
|
|
52
|
+
}
|
|
53
|
+
markUsed(requestId, usedAt) {
|
|
54
|
+
const record = this.byRequestId.get(requestId);
|
|
55
|
+
if (!record) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const updated = {
|
|
59
|
+
...record,
|
|
60
|
+
usedAt
|
|
61
|
+
};
|
|
62
|
+
this.byRequestId.set(requestId, updated);
|
|
63
|
+
this.pruneExpired(usedAt);
|
|
64
|
+
this.flushToDisk();
|
|
65
|
+
return updated;
|
|
66
|
+
}
|
|
67
|
+
pruneExpired(nowMs) {
|
|
68
|
+
for (const [id, record] of this.byRequestId) {
|
|
69
|
+
// Only prune records that are both expired AND used (or expired without a token).
|
|
70
|
+
// Used-but-not-expired records must be retained for replay detection.
|
|
71
|
+
if (record.expiresAt <= nowMs) {
|
|
72
|
+
if (record.token) {
|
|
73
|
+
this.requestIdByToken.delete(record.token);
|
|
74
|
+
}
|
|
75
|
+
this.byRequestId.delete(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
loadFromDisk() {
|
|
80
|
+
if (!this.storagePath) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const raw = fs.readFileSync(this.storagePath, "utf8");
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
if (!Array.isArray(parsed)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const nowMs = Date.now();
|
|
90
|
+
for (const record of parsed) {
|
|
91
|
+
if (!record || typeof record.requestId !== "string") {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (record.expiresAt <= nowMs || record.usedAt) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
this.byRequestId.set(record.requestId, record);
|
|
98
|
+
if (record.token) {
|
|
99
|
+
this.requestIdByToken.set(record.token, record.requestId);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Fail closed at higher layers; ignore corrupted persistence artifacts here.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
flushToDisk() {
|
|
108
|
+
if (!this.storagePath) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const dir = path.dirname(this.storagePath);
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
114
|
+
const nowMs = Date.now();
|
|
115
|
+
const records = Array.from(this.byRequestId.values()).filter((record) => !record.usedAt && record.expiresAt > nowMs);
|
|
116
|
+
const tempPath = `${this.storagePath}.tmp`;
|
|
117
|
+
fs.writeFileSync(tempPath, JSON.stringify(records, null, 2), "utf8");
|
|
118
|
+
fs.renameSync(tempPath, this.storagePath);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Best-effort durability; in-memory state remains authoritative for this process.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ApprovalRequirement } from "./detectors/types.js";
|
|
2
|
+
import { ApprovalStore } from "./approval-store.js";
|
|
3
|
+
import type { GuardDecision, GuardrailsConfig, NormalizedEvent, PrincipalRole } from "./types.js";
|
|
4
|
+
export type ApprovalVerifyResult = "valid" | "invalid" | "expired" | "replayed";
|
|
5
|
+
export interface ApprovalChallengeInput {
|
|
6
|
+
event: NormalizedEvent;
|
|
7
|
+
requirement: ApprovalRequirement;
|
|
8
|
+
nowMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildApprovalActionDigest(event: NormalizedEvent): string;
|
|
11
|
+
export declare class ApprovalBroker {
|
|
12
|
+
private readonly config;
|
|
13
|
+
private readonly store;
|
|
14
|
+
constructor(config: GuardrailsConfig, store?: ApprovalStore);
|
|
15
|
+
createChallenge({ event, requirement, nowMs }: ApprovalChallengeInput): GuardDecision["approvalChallenge"];
|
|
16
|
+
approveRequest(requestId: string, approverId: string, approverRole: PrincipalRole, nowMs?: number): string | null;
|
|
17
|
+
verifyAndConsumeToken(token: string, event: NormalizedEvent, requestId?: string, nowMs?: number): ApprovalVerifyResult;
|
|
18
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { ApprovalStore } from "./approval-store.js";
|
|
3
|
+
import { UNKNOWN_SENDER, UNKNOWN_CONVERSATION } from "./identity.js";
|
|
4
|
+
function stableStringify(value) {
|
|
5
|
+
return JSON.stringify(value, (_key, v) => {
|
|
6
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
7
|
+
const sorted = {};
|
|
8
|
+
for (const k of Object.keys(v).sort()) {
|
|
9
|
+
sorted[k] = v[k];
|
|
10
|
+
}
|
|
11
|
+
return sorted;
|
|
12
|
+
}
|
|
13
|
+
return v;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function buildApprovalActionDigest(event) {
|
|
17
|
+
const principal = event.metadata.principal;
|
|
18
|
+
const canonical = stableStringify({
|
|
19
|
+
toolName: event.toolName ?? "",
|
|
20
|
+
args: event.args ?? {},
|
|
21
|
+
dataClass: event.metadata.dataClass ?? "public",
|
|
22
|
+
conversationId: principal?.conversationId ?? UNKNOWN_CONVERSATION,
|
|
23
|
+
requesterId: principal?.senderId ?? UNKNOWN_SENDER
|
|
24
|
+
});
|
|
25
|
+
return crypto.createHash("sha256").update(canonical).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
function canRoleApprove(requiredRole, approverRole) {
|
|
28
|
+
if (requiredRole === "owner") {
|
|
29
|
+
return approverRole === "owner";
|
|
30
|
+
}
|
|
31
|
+
return approverRole === "owner" || approverRole === "admin";
|
|
32
|
+
}
|
|
33
|
+
export class ApprovalBroker {
|
|
34
|
+
config;
|
|
35
|
+
store;
|
|
36
|
+
constructor(config, store) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.store = store ?? new ApprovalStore(config.approval.storagePath, config.workspaceRoot);
|
|
39
|
+
}
|
|
40
|
+
createChallenge({ event, requirement, nowMs = Date.now() }) {
|
|
41
|
+
const principal = event.metadata.principal;
|
|
42
|
+
const requestId = crypto.randomUUID();
|
|
43
|
+
const expiresAt = nowMs + this.config.approval.ttlSeconds * 1_000;
|
|
44
|
+
const actionDigest = buildApprovalActionDigest(event);
|
|
45
|
+
const conversationId = principal?.conversationId ?? UNKNOWN_CONVERSATION;
|
|
46
|
+
const requesterId = principal?.senderId ?? UNKNOWN_SENDER;
|
|
47
|
+
const record = {
|
|
48
|
+
requestId,
|
|
49
|
+
actionDigest,
|
|
50
|
+
requesterId,
|
|
51
|
+
conversationId,
|
|
52
|
+
requiredRole: requirement.requiredRole,
|
|
53
|
+
reason: requirement.reason,
|
|
54
|
+
createdAt: nowMs,
|
|
55
|
+
expiresAt,
|
|
56
|
+
approverIds: []
|
|
57
|
+
};
|
|
58
|
+
this.store.save(record);
|
|
59
|
+
return {
|
|
60
|
+
requestId,
|
|
61
|
+
expiresAt,
|
|
62
|
+
reason: requirement.reason,
|
|
63
|
+
requiredRole: requirement.requiredRole
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
approveRequest(requestId, approverId, approverRole, nowMs = Date.now()) {
|
|
67
|
+
const record = this.store.getByRequestId(requestId);
|
|
68
|
+
if (!record) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (record.expiresAt <= nowMs) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!canRoleApprove(record.requiredRole, approverRole)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (record.requesterId === approverId) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const hasApproved = record.approverIds.includes(approverId);
|
|
81
|
+
const approverIds = hasApproved
|
|
82
|
+
? record.approverIds
|
|
83
|
+
: [...record.approverIds, approverId];
|
|
84
|
+
const quorum = Math.max(1, this.config.approval.ownerQuorum);
|
|
85
|
+
if (approverIds.length < quorum) {
|
|
86
|
+
this.store.save({
|
|
87
|
+
...record,
|
|
88
|
+
approverIds
|
|
89
|
+
});
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (record.token) {
|
|
93
|
+
return record.usedAt ? null : record.token;
|
|
94
|
+
}
|
|
95
|
+
const token = `apr_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
96
|
+
this.store.setToken(requestId, token, approverIds.join(","));
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
verifyAndConsumeToken(token, event, requestId, nowMs = Date.now()) {
|
|
100
|
+
const record = this.store.getByToken(token);
|
|
101
|
+
if (!record) {
|
|
102
|
+
return "invalid";
|
|
103
|
+
}
|
|
104
|
+
if (record.expiresAt <= nowMs) {
|
|
105
|
+
return "expired";
|
|
106
|
+
}
|
|
107
|
+
if (record.usedAt) {
|
|
108
|
+
return "replayed";
|
|
109
|
+
}
|
|
110
|
+
if (requestId && record.requestId !== requestId) {
|
|
111
|
+
return "invalid";
|
|
112
|
+
}
|
|
113
|
+
const principal = event.metadata.principal;
|
|
114
|
+
const requesterId = principal?.senderId ?? UNKNOWN_SENDER;
|
|
115
|
+
if (record.requesterId !== requesterId) {
|
|
116
|
+
return "invalid";
|
|
117
|
+
}
|
|
118
|
+
if (this.config.approval.bindToConversation &&
|
|
119
|
+
record.conversationId !== (principal?.conversationId ?? UNKNOWN_CONVERSATION)) {
|
|
120
|
+
return "invalid";
|
|
121
|
+
}
|
|
122
|
+
const digest = buildApprovalActionDigest(event);
|
|
123
|
+
if (record.actionDigest !== digest) {
|
|
124
|
+
return "invalid";
|
|
125
|
+
}
|
|
126
|
+
this.store.markUsed(record.requestId, nowMs);
|
|
127
|
+
return "valid";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GuardrailsConfig, NormalizedEvent, RuleHit } from "./types.js";
|
|
2
|
+
import type { ApprovalRequirement } from "./detectors/types.js";
|
|
3
|
+
export interface AuthorizationResult {
|
|
4
|
+
hits: RuleHit[];
|
|
5
|
+
approvalRequirement?: ApprovalRequirement;
|
|
6
|
+
}
|
|
7
|
+
export declare function evaluateAuthorization(event: NormalizedEvent, config: GuardrailsConfig): AuthorizationResult;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { REASON_CODES } from "./reason-codes.js";
|
|
2
|
+
function isOwnerRole(role) {
|
|
3
|
+
return role === "owner";
|
|
4
|
+
}
|
|
5
|
+
function shouldAllowByDefault(config) {
|
|
6
|
+
return config.authorization.defaultEffect === "allow";
|
|
7
|
+
}
|
|
8
|
+
function isRestrictedDataClass(dataClass, config) {
|
|
9
|
+
return config.authorization.restrictedDataClasses.includes(dataClass);
|
|
10
|
+
}
|
|
11
|
+
function dataClassNeedsApproval(dataClass, config) {
|
|
12
|
+
return (dataClass === "restricted" || dataClass === "secret"
|
|
13
|
+
? config.approval.requireForDataClasses.includes(dataClass)
|
|
14
|
+
: false);
|
|
15
|
+
}
|
|
16
|
+
export function evaluateAuthorization(event, config) {
|
|
17
|
+
const hits = [];
|
|
18
|
+
const principal = event.metadata.principal;
|
|
19
|
+
if (!principal) {
|
|
20
|
+
return {
|
|
21
|
+
hits: [
|
|
22
|
+
{
|
|
23
|
+
ruleId: "principal.context.missing",
|
|
24
|
+
reasonCode: REASON_CODES.PRINCIPAL_CONTEXT_MISSING,
|
|
25
|
+
decision: "DENY",
|
|
26
|
+
weight: 0.9
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (config.principal.requireContext &&
|
|
32
|
+
event.metadata.principalMissingContext &&
|
|
33
|
+
principal.channelType === "group") {
|
|
34
|
+
hits.push({
|
|
35
|
+
ruleId: "principal.context.group_missing",
|
|
36
|
+
reasonCode: REASON_CODES.PRINCIPAL_CONTEXT_MISSING,
|
|
37
|
+
decision: "DENY",
|
|
38
|
+
weight: 0.95
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (principal.channelType === "group") {
|
|
42
|
+
if (config.principal.failUnknownInGroup && principal.role === "unknown") {
|
|
43
|
+
hits.push({
|
|
44
|
+
ruleId: "principal.group.unknown_sender",
|
|
45
|
+
reasonCode: REASON_CODES.GROUP_SENDER_NOT_ALLOWED,
|
|
46
|
+
decision: "DENY",
|
|
47
|
+
weight: 0.95
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (config.authorization.requireMentionInGroups &&
|
|
51
|
+
principal.mentionedAgent !== true &&
|
|
52
|
+
(event.phase === "message_received" || event.phase === "before_tool_call")) {
|
|
53
|
+
hits.push({
|
|
54
|
+
ruleId: "principal.group.require_mention",
|
|
55
|
+
reasonCode: REASON_CODES.GROUP_SENDER_NOT_ALLOWED,
|
|
56
|
+
decision: "DENY",
|
|
57
|
+
weight: 0.7
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let approvalRequirement;
|
|
62
|
+
if (event.phase === "before_tool_call" && event.toolName) {
|
|
63
|
+
const toolName = event.toolName;
|
|
64
|
+
const roleAllowlist = config.authorization.toolAllowByRole[principal.role] ?? [];
|
|
65
|
+
const isRestrictedTool = config.authorization.restrictedTools.includes(toolName);
|
|
66
|
+
const roleAllowsTool = roleAllowlist.includes(toolName);
|
|
67
|
+
if (isRestrictedTool &&
|
|
68
|
+
!roleAllowsTool) {
|
|
69
|
+
const needsApproval = config.approval.enabled &&
|
|
70
|
+
config.approval.requireForTools.includes(toolName) &&
|
|
71
|
+
principal.role !== "owner";
|
|
72
|
+
if (needsApproval) {
|
|
73
|
+
approvalRequirement = {
|
|
74
|
+
reason: `Owner approval required for restricted tool: ${toolName}`,
|
|
75
|
+
requiredRole: "owner"
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
else if (!shouldAllowByDefault(config)) {
|
|
79
|
+
hits.push({
|
|
80
|
+
ruleId: "authorization.role.tool_restricted",
|
|
81
|
+
reasonCode: REASON_CODES.ROLE_TOOL_NOT_ALLOWED,
|
|
82
|
+
decision: "DENY",
|
|
83
|
+
weight: 0.9
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (event.phase === "before_tool_call") {
|
|
89
|
+
const dataClass = (event.metadata.dataClass ?? "public");
|
|
90
|
+
if (isRestrictedDataClass(dataClass, config) && !isOwnerRole(principal.role)) {
|
|
91
|
+
const needsApproval = config.approval.enabled &&
|
|
92
|
+
dataClassNeedsApproval(dataClass, config) &&
|
|
93
|
+
principal.role !== "owner";
|
|
94
|
+
if (needsApproval && !approvalRequirement) {
|
|
95
|
+
approvalRequirement = {
|
|
96
|
+
reason: `Owner approval required for ${dataClass} data access`,
|
|
97
|
+
requiredRole: "owner"
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
else if (!needsApproval && !shouldAllowByDefault(config)) {
|
|
101
|
+
hits.push({
|
|
102
|
+
ruleId: "authorization.data_class.restricted",
|
|
103
|
+
reasonCode: REASON_CODES.RESTRICTED_INFO_ROLE_BLOCKED,
|
|
104
|
+
decision: "DENY",
|
|
105
|
+
weight: 0.9
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
hits,
|
|
112
|
+
approvalRequirement
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class BudgetStore {
|
|
2
|
+
perKey = new Map();
|
|
3
|
+
checkAndRecord(subjectKey, kind, limitPerMinute, nowMs = Date.now()) {
|
|
4
|
+
if (limitPerMinute <= 0) {
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
const budget = this.getOrCreateAgentBudget(subjectKey);
|
|
8
|
+
const bucket = kind === "request" ? budget.requests : budget.toolCalls;
|
|
9
|
+
const windowStart = nowMs - 60_000;
|
|
10
|
+
while (bucket.length > 0 && bucket[0] < windowStart) {
|
|
11
|
+
bucket.shift();
|
|
12
|
+
}
|
|
13
|
+
bucket.push(nowMs);
|
|
14
|
+
const exceeded = bucket.length > limitPerMinute;
|
|
15
|
+
// Prune empty entries to prevent unbounded Map growth from high-cardinality keys.
|
|
16
|
+
if (budget.requests.length === 0 && budget.toolCalls.length === 0) {
|
|
17
|
+
this.perKey.delete(subjectKey);
|
|
18
|
+
}
|
|
19
|
+
return exceeded;
|
|
20
|
+
}
|
|
21
|
+
getOrCreateAgentBudget(subjectKey) {
|
|
22
|
+
const current = this.perKey.get(subjectKey);
|
|
23
|
+
if (current) {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
const created = {
|
|
27
|
+
requests: [],
|
|
28
|
+
toolCalls: []
|
|
29
|
+
};
|
|
30
|
+
this.perKey.set(subjectKey, created);
|
|
31
|
+
return created;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ParsedCommand {
|
|
2
|
+
raw: string;
|
|
3
|
+
binary: string;
|
|
4
|
+
args: string;
|
|
5
|
+
tokens: string[];
|
|
6
|
+
hasShellOperators: boolean;
|
|
7
|
+
operatorHits: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function extractCommandFromArgs(args: Record<string, unknown>): string | undefined;
|
|
10
|
+
export declare function extractUrlCandidatesFromCommand(command: string): string[];
|
|
11
|
+
export declare function parseCommand(rawCommand: string, shellOperatorPatterns: string[]): ParsedCommand;
|