Skip to content

Commit 8179766

Browse files
authored
Add group-scoped policy layers (#77)
1 parent 8405cd8 commit 8179766

23 files changed

Lines changed: 935 additions & 63 deletions

File tree

cli/src/commands/fake-hook.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface FakeHookOptions {
1111
tool: string;
1212
command?: string;
1313
filePath?: string;
14+
cwd?: string;
1415
url?: string;
1516
json: boolean;
1617
inputJson?: string;
@@ -34,6 +35,7 @@ export async function runFakeHook(opts: FakeHookOptions): Promise<void> {
3435
source: opts.source,
3536
tool: opts.tool,
3637
input,
38+
cwd: opts.cwd,
3739
};
3840
const client = apiClient(opts.url);
3941
let res: GateCheckResponse;

cli/src/commands/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ interface Options {
1919
policyHash?: string;
2020
code?: string;
2121
passphrase?: string;
22+
userId?: string;
23+
groups?: string[];
2224
}
2325

2426
export async function runSessionEnd(opts: {
@@ -82,6 +84,8 @@ export async function runSessionRotate(opts: Options & { id: string }): Promise<
8284
signer: payload.signer,
8385
signer_pubkey: payload.signer_pubkey,
8486
attestation: `ed25519:${toHex(attestation)}`,
87+
user_id: opts.userId,
88+
groups: opts.groups,
8589
};
8690

8791
const client = apiClient(opts.url);
@@ -152,6 +156,8 @@ export async function runSessionCreate(opts: Options): Promise<void> {
152156
signer: payload.signer,
153157
signer_pubkey: payload.signer_pubkey,
154158
attestation: `ed25519:${toHex(attestation)}`,
159+
user_id: opts.userId,
160+
groups: opts.groups,
155161
};
156162

157163
const client = apiClient(opts.url);

cli/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ session
163163
.option("--tier <tier>", "Signer tier (software | totp).", "software")
164164
.option("--url <url>", "Control-plane base URL.")
165165
.option("--policy-hash <hash>", "Policy hash to bind into the attestation.")
166+
.option("--user-id <id>", "User identity used for group-policy overlays.")
167+
.option("--group <name...>", "Group memberships used for group-policy overlays.")
166168
.option("--code <6-digit>", "TOTP code (required for --tier totp).")
167169
.option("--passphrase <pp>", "TOTP passphrase (required for --tier totp).")
168170
.option("--json", "Emit JSON instead of human output.", false)
@@ -171,6 +173,8 @@ session
171173
tier: string;
172174
url?: string;
173175
policyHash?: string;
176+
userId?: string;
177+
group?: string[];
174178
code?: string;
175179
passphrase?: string;
176180
json: boolean;
@@ -197,6 +201,8 @@ session
197201
url: opts.url,
198202
json: opts.json,
199203
policyHash: opts.policyHash,
204+
userId: opts.userId,
205+
groups: opts.group,
200206
code: opts.code,
201207
passphrase: opts.passphrase,
202208
});
@@ -220,6 +226,8 @@ session
220226
.option("--tier <tier>", "Signer tier (software | totp).", "software")
221227
.option("--url <url>", "Control-plane base URL.")
222228
.option("--policy-hash <hash>", "Policy hash to bind into the rotated attestation.")
229+
.option("--user-id <id>", "User identity used for group-policy overlays.")
230+
.option("--group <name...>", "Group memberships used for group-policy overlays.")
223231
.option("--code <6-digit>", "TOTP code (required for --tier totp).")
224232
.option("--passphrase <pp>", "TOTP passphrase (required for --tier totp).")
225233
.option("--json", "Emit JSON instead of human output.", false)
@@ -229,6 +237,8 @@ session
229237
tier: string;
230238
url?: string;
231239
policyHash?: string;
240+
userId?: string;
241+
group?: string[];
232242
code?: string;
233243
passphrase?: string;
234244
json: boolean;
@@ -252,6 +262,8 @@ session
252262
url: opts.url,
253263
json: opts.json,
254264
policyHash: opts.policyHash,
265+
userId: opts.userId,
266+
groups: opts.group,
255267
code: opts.code,
256268
passphrase: opts.passphrase,
257269
});
@@ -362,6 +374,7 @@ program
362374
.requiredOption("--tool <name>", "Tool name (Bash, Read, Write, mcp__X__Y).")
363375
.option("--command <cmd>", "Bash command (shorthand for --input.command).")
364376
.option("--file-path <path>", "File path (shorthand for --input.file_path).")
377+
.option("--cwd <path>", "Working directory for scoped policy resolution.")
365378
.option("--input <json>", "Raw tool input as JSON.")
366379
.option("--url <url>", "Control-plane base URL.")
367380
.option("--json", "Emit JSON instead of human output.", false)
@@ -372,6 +385,7 @@ program
372385
tool: string;
373386
command?: string;
374387
filePath?: string;
388+
cwd?: string;
375389
input?: string;
376390
url?: string;
377391
json: boolean;
@@ -382,6 +396,7 @@ program
382396
tool: opts.tool,
383397
command: opts.command,
384398
filePath: opts.filePath,
399+
cwd: opts.cwd,
385400
inputJson: opts.input,
386401
url: opts.url,
387402
json: opts.json,

cli/src/util/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export interface PolicyGateView {
132132
id: string;
133133
mode?: string;
134134
disabled?: boolean;
135+
source?: string;
135136
tool?: string;
136137
tool_prefix?: string;
137138
any_command_regex?: string[];
@@ -283,6 +284,8 @@ export interface SessionStartRequest {
283284
signer: string;
284285
signer_pubkey: string;
285286
attestation: string;
287+
user_id?: string;
288+
groups?: string[];
286289
}
287290

288291
export interface SessionResponse {
@@ -293,6 +296,8 @@ export interface SessionResponse {
293296
session_pubkey: string;
294297
signer: string;
295298
signer_pubkey: string;
299+
user_id?: string;
300+
groups?: string[];
296301
}
297302

298303
export function apiClient(baseUrl?: string, initialToken?: string | null): ApiClient {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://openagentlock.github.io/OpenAgentLock/schema/group-policy.schema.json",
4+
"title": "OpenAgentLock group policy bundle",
5+
"type": "object",
6+
"required": ["version"],
7+
"additionalProperties": false,
8+
"properties": {
9+
"version": { "type": "integer", "const": 1 },
10+
"groups": {
11+
"type": "object",
12+
"additionalProperties": { "$ref": "#/definitions/policyLayer" }
13+
},
14+
"users": {
15+
"type": "object",
16+
"additionalProperties": {
17+
"type": "object",
18+
"additionalProperties": false,
19+
"properties": {
20+
"groups": {
21+
"type": "array",
22+
"items": { "type": "string", "minLength": 1 },
23+
"uniqueItems": true
24+
},
25+
"gates": {
26+
"type": "array",
27+
"items": { "$ref": "#/definitions/gate" }
28+
}
29+
}
30+
}
31+
}
32+
},
33+
"definitions": {
34+
"policyLayer": {
35+
"type": "object",
36+
"additionalProperties": false,
37+
"properties": {
38+
"inherits": {
39+
"type": "array",
40+
"items": { "type": "string", "minLength": 1 },
41+
"uniqueItems": true
42+
},
43+
"gates": {
44+
"type": "array",
45+
"items": { "$ref": "#/definitions/gate" }
46+
}
47+
}
48+
},
49+
"gate": {
50+
"type": "object",
51+
"required": ["id", "match", "evaluate"],
52+
"additionalProperties": true,
53+
"properties": {
54+
"id": { "type": "string", "minLength": 1 },
55+
"source": { "type": "string" },
56+
"mode": { "type": "string", "enum": ["monitor", "enforce"] },
57+
"disabled": { "type": "boolean" },
58+
"precedence": { "type": "string", "enum": ["priority"] },
59+
"priority": { "type": "integer" },
60+
"match": { "type": "object" },
61+
"evaluate": {
62+
"type": "array",
63+
"minItems": 1,
64+
"items": { "type": "object" }
65+
}
66+
}
67+
}
68+
}
69+
}

control-plane/api/openapi.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ components:
268268
session_pubkey: { type: string }
269269
signer: { $ref: '#/components/schemas/SignerKind' }
270270
signer_pubkey: { type: string }
271+
user_id: { type: string }
272+
groups: { type: array, items: { type: string } }
271273

272274
SessionStartRequest:
273275
type: object
@@ -278,6 +280,8 @@ components:
278280
signer: { $ref: '#/components/schemas/SignerKind' }
279281
signer_pubkey: { type: string }
280282
attestation: { type: string, description: "ed25519 signature over canonical attestation payload" }
283+
user_id: { type: string, description: "Optional identity key used for group-policy overlays." }
284+
groups: { type: array, items: { type: string }, description: "Optional ordered group memberships used for group-policy overlays." }
281285

282286
GateCheckRequest:
283287
type: object

control-plane/dashboard-ui/src/lib/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,22 @@ export interface LedgerEntry {
1313
// mode forced the runtime to allow. UI renders these as "alert"
1414
// (IDS-style) instead of "deny" (IPS-style) — the call did go through.
1515
monitor_match?: boolean;
16+
policy_trace?: PolicyTraceItem[];
1617
payload_hash: string;
1718
sig: string;
1819
leaf_hash: string;
1920
prev_leaf: string;
2021
}
2122

23+
export interface PolicyTraceItem {
24+
layer?: string;
25+
source?: string;
26+
rule_id: string;
27+
verdict: string;
28+
precedence?: string;
29+
priority?: number;
30+
}
31+
2232
export interface ModeInfo {
2333
mode: "firewall" | "monitor";
2434
env: string;

control-plane/dashboard-ui/src/routes/events.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,20 @@ function EventDetail({
452452
value="suppressed deny — runtime allowed; ledger keeps original verdict"
453453
/>
454454
)}
455+
{entry.policy_trace && entry.policy_trace.length > 0 && (
456+
<DetailRow
457+
label="policy"
458+
value={entry.policy_trace
459+
.map((t) => {
460+
const priority =
461+
t.precedence === "priority" ? ` priority=${t.priority ?? 0}` : "";
462+
return `${t.layer || t.source || "policy"}:${t.rule_id}=${t.verdict}${priority}`;
463+
})
464+
.join(" → ")}
465+
mono
466+
wrap
467+
/>
468+
)}
455469
<DetailRow label="signer" value={entry.signer || "—"} mono />
456470
<DetailRow label="tool_use_id" value={entry.tool_use_id || "—"} mono />
457471
<DetailRow label="payload_hash" value={entry.payload_hash} mono wrap />

control-plane/internal/api/gate.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"net/http"
99
"time"
1010

11-
"github.com/openagentlock/openagentlock/control-plane/internal/policy"
1211
"github.com/openagentlock/openagentlock/control-plane/internal/storage"
1312
)
1413

@@ -66,15 +65,11 @@ func gateCheckHandler(d Deps) http.HandlerFunc {
6665

6766
// Resolve the policy pinned to this session's hash; falls back to
6867
// live when the hash is unknown (e.g. registry not yet seeded).
69-
evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, req.Cwd)
68+
evalPolicy, result := evaluatePolicyForSession(d, sess, req.Cwd, req.Tool, req.Input)
7069
if evalPolicy == nil {
7170
writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded")
7271
return
7372
}
74-
result := evalPolicy.Evaluate(policy.EvalRequest{
75-
Tool: req.Tool,
76-
Input: req.Input,
77-
})
7873

7974
var origVerdict string
8075
result, _, origVerdict = applyDaemonModeOverride(result)
@@ -107,6 +102,7 @@ func gateCheckHandler(d Deps) http.HandlerFunc {
107102
Verdict: origVerdict,
108103
MonitorMatch: result.MonitorMatch,
109104
MatcherInput: ledgerMatcherInput(req.Input),
105+
PolicyTrace: storagePolicyTrace(result.Trace),
110106
PayloadHash: payloadHash[:],
111107
Sig: nil,
112108
})

0 commit comments

Comments
 (0)