@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.
Files changed (73) hide show
  1. package/README.md +182 -0
  2. package/dist/core/approval-store.d.ts +29 -0
  3. package/dist/core/approval-store.js +124 -0
  4. package/dist/core/approval.d.ts +18 -0
  5. package/dist/core/approval.js +129 -0
  6. package/dist/core/authorization.d.ts +7 -0
  7. package/dist/core/authorization.js +114 -0
  8. package/dist/core/budget-store.d.ts +6 -0
  9. package/dist/core/budget-store.js +33 -0
  10. package/dist/core/command-parse.d.ts +11 -0
  11. package/dist/core/command-parse.js +67 -0
  12. package/dist/core/detectors/budget-detector.d.ts +4 -0
  13. package/dist/core/detectors/budget-detector.js +35 -0
  14. package/dist/core/detectors/command-policy-detector.d.ts +3 -0
  15. package/dist/core/detectors/command-policy-detector.js +74 -0
  16. package/dist/core/detectors/index.d.ts +11 -0
  17. package/dist/core/detectors/index.js +11 -0
  18. package/dist/core/detectors/input-intent-detector.d.ts +3 -0
  19. package/dist/core/detectors/input-intent-detector.js +55 -0
  20. package/dist/core/detectors/network-egress-detector.d.ts +3 -0
  21. package/dist/core/detectors/network-egress-detector.js +68 -0
  22. package/dist/core/detectors/output-safety-detector.d.ts +2 -0
  23. package/dist/core/detectors/output-safety-detector.js +36 -0
  24. package/dist/core/detectors/owner-approval-detector.d.ts +4 -0
  25. package/dist/core/detectors/owner-approval-detector.js +62 -0
  26. package/dist/core/detectors/path-canonical-detector.d.ts +3 -0
  27. package/dist/core/detectors/path-canonical-detector.js +46 -0
  28. package/dist/core/detectors/principal-authz-detector.d.ts +3 -0
  29. package/dist/core/detectors/principal-authz-detector.js +14 -0
  30. package/dist/core/detectors/provenance-detector.d.ts +3 -0
  31. package/dist/core/detectors/provenance-detector.js +8 -0
  32. package/dist/core/detectors/restricted-info-detector.d.ts +2 -0
  33. package/dist/core/detectors/restricted-info-detector.js +50 -0
  34. package/dist/core/detectors/sensitive-data-detector.d.ts +2 -0
  35. package/dist/core/detectors/sensitive-data-detector.js +55 -0
  36. package/dist/core/detectors/types.d.ts +20 -0
  37. package/dist/core/detectors/types.js +1 -0
  38. package/dist/core/engine.d.ts +12 -0
  39. package/dist/core/engine.js +123 -0
  40. package/dist/core/event-utils.d.ts +10 -0
  41. package/dist/core/event-utils.js +105 -0
  42. package/dist/core/identity.d.ts +8 -0
  43. package/dist/core/identity.js +102 -0
  44. package/dist/core/network-guard.d.ts +5 -0
  45. package/dist/core/network-guard.js +134 -0
  46. package/dist/core/normalize.d.ts +2 -0
  47. package/dist/core/normalize.js +60 -0
  48. package/dist/core/path-canonical.d.ts +9 -0
  49. package/dist/core/path-canonical.js +69 -0
  50. package/dist/core/reason-codes.d.ts +43 -0
  51. package/dist/core/reason-codes.js +42 -0
  52. package/dist/core/retrieval-trust.d.ts +2 -0
  53. package/dist/core/retrieval-trust.js +65 -0
  54. package/dist/core/scoring.d.ts +2 -0
  55. package/dist/core/scoring.js +18 -0
  56. package/dist/core/supply-chain.d.ts +2 -0
  57. package/dist/core/supply-chain.js +78 -0
  58. package/dist/core/types.d.ts +149 -0
  59. package/dist/core/types.js +1 -0
  60. package/dist/index.d.ts +7 -0
  61. package/dist/index.js +6 -0
  62. package/dist/plugin/openclaw-adapter.d.ts +41 -0
  63. package/dist/plugin/openclaw-adapter.js +313 -0
  64. package/dist/plugin/openclaw-extension.d.ts +14 -0
  65. package/dist/plugin/openclaw-extension.js +62 -0
  66. package/dist/redaction/redact.d.ts +6 -0
  67. package/dist/redaction/redact.js +31 -0
  68. package/dist/rules/default-policy.d.ts +3 -0
  69. package/dist/rules/default-policy.js +200 -0
  70. package/dist/rules/patterns.d.ts +8 -0
  71. package/dist/rules/patterns.js +64 -0
  72. package/openclaw.plugin.json +147 -0
  73. 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,6 @@
1
+ export type BudgetKind = "request" | "toolCall";
2
+ export declare class BudgetStore {
3
+ private readonly perKey;
4
+ checkAndRecord(subjectKey: string, kind: BudgetKind, limitPerMinute: number, nowMs?: number): boolean;
5
+ private getOrCreateAgentBudget;
6
+ }
@@ -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;