From 3828080d8cabfe0787a0ef1950418dc9bdbc0762 Mon Sep 17 00:00:00 2001 From: knhn1004 <49494541+knhn1004@users.noreply.github.com> Date: Wed, 6 May 2026 17:14:44 -0700 Subject: [PATCH] Add group-scoped policy layers --- cli/src/commands/fake-hook.ts | 2 + cli/src/commands/session.ts | 6 + cli/src/index.ts | 15 ++ cli/src/util/api.ts | 5 + control-plane/api/group-policy.schema.json | 69 +++++++ control-plane/api/openapi.yaml | 4 + control-plane/dashboard-ui/src/lib/types.ts | 10 + .../dashboard-ui/src/routes/events.tsx | 14 ++ control-plane/internal/api/gate.go | 8 +- control-plane/internal/api/gate_test.go | 177 +++++++++++++++++ control-plane/internal/api/group_policy.go | 187 ++++++++++++++++++ control-plane/internal/api/hooks_claude.go | 8 +- control-plane/internal/api/hooks_codex.go | 8 +- control-plane/internal/api/hooks_cursor.go | 15 +- control-plane/internal/api/hooks_gemini.go | 8 +- control-plane/internal/api/session.go | 22 ++- control-plane/internal/policy/policy.go | 174 ++++++++++++++-- control-plane/internal/policy/policy_test.go | 95 ++++++++- control-plane/internal/storage/memory.go | 16 +- docs/architecture/group-policy.md | 126 ++++++++++++ docs/guide/policies.md | 23 +++ docs/reference/api.md | 4 + docs/status.md | 2 +- 23 files changed, 935 insertions(+), 63 deletions(-) create mode 100644 control-plane/api/group-policy.schema.json create mode 100644 control-plane/internal/api/group_policy.go create mode 100644 docs/architecture/group-policy.md diff --git a/cli/src/commands/fake-hook.ts b/cli/src/commands/fake-hook.ts index 4347b69..ff0d443 100644 --- a/cli/src/commands/fake-hook.ts +++ b/cli/src/commands/fake-hook.ts @@ -11,6 +11,7 @@ export interface FakeHookOptions { tool: string; command?: string; filePath?: string; + cwd?: string; url?: string; json: boolean; inputJson?: string; @@ -34,6 +35,7 @@ export async function runFakeHook(opts: FakeHookOptions): Promise { source: opts.source, tool: opts.tool, input, + cwd: opts.cwd, }; const client = apiClient(opts.url); let res: GateCheckResponse; diff --git a/cli/src/commands/session.ts b/cli/src/commands/session.ts index 8dd32d1..282a8b4 100644 --- a/cli/src/commands/session.ts +++ b/cli/src/commands/session.ts @@ -19,6 +19,8 @@ interface Options { policyHash?: string; code?: string; passphrase?: string; + userId?: string; + groups?: string[]; } export async function runSessionEnd(opts: { @@ -82,6 +84,8 @@ export async function runSessionRotate(opts: Options & { id: string }): Promise< signer: payload.signer, signer_pubkey: payload.signer_pubkey, attestation: `ed25519:${toHex(attestation)}`, + user_id: opts.userId, + groups: opts.groups, }; const client = apiClient(opts.url); @@ -152,6 +156,8 @@ export async function runSessionCreate(opts: Options): Promise { signer: payload.signer, signer_pubkey: payload.signer_pubkey, attestation: `ed25519:${toHex(attestation)}`, + user_id: opts.userId, + groups: opts.groups, }; const client = apiClient(opts.url); diff --git a/cli/src/index.ts b/cli/src/index.ts index 528e4a4..d475345 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -163,6 +163,8 @@ session .option("--tier ", "Signer tier (software | totp).", "software") .option("--url ", "Control-plane base URL.") .option("--policy-hash ", "Policy hash to bind into the attestation.") + .option("--user-id ", "User identity used for group-policy overlays.") + .option("--group ", "Group memberships used for group-policy overlays.") .option("--code <6-digit>", "TOTP code (required for --tier totp).") .option("--passphrase ", "TOTP passphrase (required for --tier totp).") .option("--json", "Emit JSON instead of human output.", false) @@ -171,6 +173,8 @@ session tier: string; url?: string; policyHash?: string; + userId?: string; + group?: string[]; code?: string; passphrase?: string; json: boolean; @@ -197,6 +201,8 @@ session url: opts.url, json: opts.json, policyHash: opts.policyHash, + userId: opts.userId, + groups: opts.group, code: opts.code, passphrase: opts.passphrase, }); @@ -220,6 +226,8 @@ session .option("--tier ", "Signer tier (software | totp).", "software") .option("--url ", "Control-plane base URL.") .option("--policy-hash ", "Policy hash to bind into the rotated attestation.") + .option("--user-id ", "User identity used for group-policy overlays.") + .option("--group ", "Group memberships used for group-policy overlays.") .option("--code <6-digit>", "TOTP code (required for --tier totp).") .option("--passphrase ", "TOTP passphrase (required for --tier totp).") .option("--json", "Emit JSON instead of human output.", false) @@ -229,6 +237,8 @@ session tier: string; url?: string; policyHash?: string; + userId?: string; + group?: string[]; code?: string; passphrase?: string; json: boolean; @@ -252,6 +262,8 @@ session url: opts.url, json: opts.json, policyHash: opts.policyHash, + userId: opts.userId, + groups: opts.group, code: opts.code, passphrase: opts.passphrase, }); @@ -362,6 +374,7 @@ program .requiredOption("--tool ", "Tool name (Bash, Read, Write, mcp__X__Y).") .option("--command ", "Bash command (shorthand for --input.command).") .option("--file-path ", "File path (shorthand for --input.file_path).") + .option("--cwd ", "Working directory for scoped policy resolution.") .option("--input ", "Raw tool input as JSON.") .option("--url ", "Control-plane base URL.") .option("--json", "Emit JSON instead of human output.", false) @@ -372,6 +385,7 @@ program tool: string; command?: string; filePath?: string; + cwd?: string; input?: string; url?: string; json: boolean; @@ -382,6 +396,7 @@ program tool: opts.tool, command: opts.command, filePath: opts.filePath, + cwd: opts.cwd, inputJson: opts.input, url: opts.url, json: opts.json, diff --git a/cli/src/util/api.ts b/cli/src/util/api.ts index 6007921..046bce2 100644 --- a/cli/src/util/api.ts +++ b/cli/src/util/api.ts @@ -132,6 +132,7 @@ export interface PolicyGateView { id: string; mode?: string; disabled?: boolean; + source?: string; tool?: string; tool_prefix?: string; any_command_regex?: string[]; @@ -283,6 +284,8 @@ export interface SessionStartRequest { signer: string; signer_pubkey: string; attestation: string; + user_id?: string; + groups?: string[]; } export interface SessionResponse { @@ -293,6 +296,8 @@ export interface SessionResponse { session_pubkey: string; signer: string; signer_pubkey: string; + user_id?: string; + groups?: string[]; } export function apiClient(baseUrl?: string, initialToken?: string | null): ApiClient { diff --git a/control-plane/api/group-policy.schema.json b/control-plane/api/group-policy.schema.json new file mode 100644 index 0000000..3b35ce2 --- /dev/null +++ b/control-plane/api/group-policy.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://openagentlock.github.io/OpenAgentLock/schema/group-policy.schema.json", + "title": "OpenAgentLock group policy bundle", + "type": "object", + "required": ["version"], + "additionalProperties": false, + "properties": { + "version": { "type": "integer", "const": 1 }, + "groups": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/policyLayer" } + }, + "users": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "groups": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "gates": { + "type": "array", + "items": { "$ref": "#/definitions/gate" } + } + } + } + } + }, + "definitions": { + "policyLayer": { + "type": "object", + "additionalProperties": false, + "properties": { + "inherits": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "gates": { + "type": "array", + "items": { "$ref": "#/definitions/gate" } + } + } + }, + "gate": { + "type": "object", + "required": ["id", "match", "evaluate"], + "additionalProperties": true, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "source": { "type": "string" }, + "mode": { "type": "string", "enum": ["monitor", "enforce"] }, + "disabled": { "type": "boolean" }, + "precedence": { "type": "string", "enum": ["priority"] }, + "priority": { "type": "integer" }, + "match": { "type": "object" }, + "evaluate": { + "type": "array", + "minItems": 1, + "items": { "type": "object" } + } + } + } + } +} diff --git a/control-plane/api/openapi.yaml b/control-plane/api/openapi.yaml index fb7ea5a..9f3aadf 100644 --- a/control-plane/api/openapi.yaml +++ b/control-plane/api/openapi.yaml @@ -268,6 +268,8 @@ components: session_pubkey: { type: string } signer: { $ref: '#/components/schemas/SignerKind' } signer_pubkey: { type: string } + user_id: { type: string } + groups: { type: array, items: { type: string } } SessionStartRequest: type: object @@ -278,6 +280,8 @@ components: signer: { $ref: '#/components/schemas/SignerKind' } signer_pubkey: { type: string } attestation: { type: string, description: "ed25519 signature over canonical attestation payload" } + user_id: { type: string, description: "Optional identity key used for group-policy overlays." } + groups: { type: array, items: { type: string }, description: "Optional ordered group memberships used for group-policy overlays." } GateCheckRequest: type: object diff --git a/control-plane/dashboard-ui/src/lib/types.ts b/control-plane/dashboard-ui/src/lib/types.ts index 9be60b2..636dc78 100644 --- a/control-plane/dashboard-ui/src/lib/types.ts +++ b/control-plane/dashboard-ui/src/lib/types.ts @@ -13,12 +13,22 @@ export interface LedgerEntry { // mode forced the runtime to allow. UI renders these as "alert" // (IDS-style) instead of "deny" (IPS-style) — the call did go through. monitor_match?: boolean; + policy_trace?: PolicyTraceItem[]; payload_hash: string; sig: string; leaf_hash: string; prev_leaf: string; } +export interface PolicyTraceItem { + layer?: string; + source?: string; + rule_id: string; + verdict: string; + precedence?: string; + priority?: number; +} + export interface ModeInfo { mode: "firewall" | "monitor"; env: string; diff --git a/control-plane/dashboard-ui/src/routes/events.tsx b/control-plane/dashboard-ui/src/routes/events.tsx index fb42034..1af17af 100644 --- a/control-plane/dashboard-ui/src/routes/events.tsx +++ b/control-plane/dashboard-ui/src/routes/events.tsx @@ -452,6 +452,20 @@ function EventDetail({ value="suppressed deny — runtime allowed; ledger keeps original verdict" /> )} + {entry.policy_trace && entry.policy_trace.length > 0 && ( + { + const priority = + t.precedence === "priority" ? ` priority=${t.priority ?? 0}` : ""; + return `${t.layer || t.source || "policy"}:${t.rule_id}=${t.verdict}${priority}`; + }) + .join(" → ")} + mono + wrap + /> + )} diff --git a/control-plane/internal/api/gate.go b/control-plane/internal/api/gate.go index cfd939a..f3bfef1 100644 --- a/control-plane/internal/api/gate.go +++ b/control-plane/internal/api/gate.go @@ -8,7 +8,6 @@ import ( "net/http" "time" - "github.com/openagentlock/openagentlock/control-plane/internal/policy" "github.com/openagentlock/openagentlock/control-plane/internal/storage" ) @@ -66,15 +65,11 @@ func gateCheckHandler(d Deps) http.HandlerFunc { // Resolve the policy pinned to this session's hash; falls back to // live when the hash is unknown (e.g. registry not yet seeded). - evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, req.Cwd) + evalPolicy, result := evaluatePolicyForSession(d, sess, req.Cwd, req.Tool, req.Input) if evalPolicy == nil { writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded") return } - result := evalPolicy.Evaluate(policy.EvalRequest{ - Tool: req.Tool, - Input: req.Input, - }) var origVerdict string result, _, origVerdict = applyDaemonModeOverride(result) @@ -107,6 +102,7 @@ func gateCheckHandler(d Deps) http.HandlerFunc { Verdict: origVerdict, MonitorMatch: result.MonitorMatch, MatcherInput: ledgerMatcherInput(req.Input), + PolicyTrace: storagePolicyTrace(result.Trace), PayloadHash: payloadHash[:], Sig: nil, }) diff --git a/control-plane/internal/api/gate_test.go b/control-plane/internal/api/gate_test.go index 76ea3f3..c5f9f52 100644 --- a/control-plane/internal/api/gate_test.go +++ b/control-plane/internal/api/gate_test.go @@ -3,6 +3,7 @@ package api import ( "bufio" "bytes" + "context" "crypto/ed25519" "encoding/hex" "encoding/json" @@ -308,6 +309,182 @@ gates: } } +func TestGateCheck_GroupPoliciesBothApply(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + setSessionIdentity(t, fx, "alice", []string{"cloud", "secrets"}) + writeGroupPolicy(t, fx.home, ` +version: 1 +groups: + cloud: + gates: + - id: group.cloud-delete + match: + tool: Bash + command_regex: '^terraform destroy' + evaluate: + - kind: always + action: deny + secrets: + gates: + - id: group.secret-print + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: deny +`) + + for _, tc := range []struct { + cmd string + rule string + }{ + {"terraform destroy -auto-approve", "group.cloud-delete"}, + {"cat secret.txt", "group.secret-print"}, + } { + body := fmt.Sprintf(`{"session_id":%q,"source":"codex","tool":"Bash","input":{"command":%q}}`, fx.sessionID, tc.cmd) + res, out := postGateCheck(t, fx.srv, body) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + if out["verdict"] != "deny" || out["rule_id"] != tc.rule { + t.Fatalf("%q got %+v, want deny by %s", tc.cmd, out, tc.rule) + } + } +} + +func TestGateCheck_GroupDenyBeatsPersonalAllow(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + setSessionIdentity(t, fx, "alice", []string{"compliance"}) + writeGroupPolicy(t, fx.home, ` +version: 1 +groups: + compliance: + gates: + - id: shared.secret + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: deny +users: + alice: + gates: + - id: user.secret-allow + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: allow +`) + body := fmt.Sprintf(`{"session_id":%q,"source":"codex","tool":"Bash","input":{"command":"cat secret.txt"}}`, fx.sessionID) + res, out := postGateCheck(t, fx.srv, body) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + if out["verdict"] != "deny" || out["rule_id"] != "shared.secret" { + t.Fatalf("group deny should win, got %+v", out) + } + entries, err := fx.store.ListLedger(context.Background()) + if err != nil { + t.Fatalf("ListLedger: %v", err) + } + if len(entries) == 0 { + t.Fatal("expected ledger entries, got none") + } + last := entries[len(entries)-1] + if len(last.PolicyTrace) != 2 { + t.Fatalf("policy trace should include group deny + user allow, got %+v", last.PolicyTrace) + } +} + +func TestGateCheck_PriorityPrecedenceCanOverrideSameGateID(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + setSessionIdentity(t, fx, "alice", []string{"default", "red-team"}) + writeGroupPolicy(t, fx.home, ` +version: 1 +groups: + default: + gates: + - id: shared.net + precedence: priority + priority: 10 + match: + tool: Bash + command_regex: '^curl ' + evaluate: + - kind: always + action: deny + red-team: + gates: + - id: shared.net + precedence: priority + priority: 20 + match: + tool: Bash + command_regex: '^curl ' + evaluate: + - kind: always + action: allow +`) + body := fmt.Sprintf(`{"session_id":%q,"source":"codex","tool":"Bash","input":{"command":"curl https://example.com"}}`, fx.sessionID) + res, out := postGateCheck(t, fx.srv, body) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + if out["verdict"] != "allow" || out["rule_id"] != "shared.net" { + t.Fatalf("priority allow should win, got %+v", out) + } +} + +func TestGateCheck_ZeroGroupsUsesBasePolicyOnly(t *testing.T) { + fx := newGateFixture(t, enforcePolicyYAML) + setSessionIdentity(t, fx, "alice", nil) + writeGroupPolicy(t, fx.home, ` +version: 1 +groups: + compliance: + gates: + - id: group.secret-print + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: deny +`) + body := fmt.Sprintf(`{"session_id":%q,"source":"codex","tool":"Bash","input":{"command":"cat secret.txt"}}`, fx.sessionID) + res, out := postGateCheck(t, fx.srv, body) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + if out["verdict"] != "allow" || out["rule_id"] != "default" { + t.Fatalf("zero groups should not inherit group policy, got %+v", out) + } +} + +func setSessionIdentity(t *testing.T, fx gateFixture, userID string, groups []string) { + t.Helper() + sess, err := fx.store.GetSession(context.Background(), fx.sessionID) + if err != nil { + t.Fatal(err) + } + sess.UserID = userID + sess.Groups = groups + if err := fx.store.UpdateSession(context.Background(), sess); err != nil { + t.Fatal(err) + } +} + +func writeGroupPolicy(t *testing.T, home string, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(home, groupPolicyFileName), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + func TestGateCheck_WritesLedgerEntry(t *testing.T) { fx := newGateFixture(t, enforcePolicyYAML) diff --git a/control-plane/internal/api/group_policy.go b/control-plane/internal/api/group_policy.go new file mode 100644 index 0000000..89971fc --- /dev/null +++ b/control-plane/internal/api/group_policy.go @@ -0,0 +1,187 @@ +package api + +import ( + "log" + "os" + "path/filepath" + "strings" + + "github.com/openagentlock/openagentlock/control-plane/internal/policy" + "github.com/openagentlock/openagentlock/control-plane/internal/storage" + "gopkg.in/yaml.v3" +) + +const groupPolicyFileName = "group-policy.yaml" + +type groupPolicyFile struct { + Version int `yaml:"version"` + Groups map[string]groupSpec `yaml:"groups"` + Users map[string]personalSpec `yaml:"users"` +} + +type groupSpec struct { + Inherits []string `yaml:"inherits"` + Gates []map[string]any `yaml:"gates"` +} + +type personalSpec struct { + Groups []string `yaml:"groups"` + Gates []map[string]any `yaml:"gates"` +} + +type policyDoc struct { + Version int `yaml:"version"` + Mode string `yaml:"mode,omitempty"` + Gates []map[string]any `yaml:"gates"` +} + +func evaluatePolicyForSession(d Deps, sess storage.Session, cwd, tool string, input map[string]any) (*policy.Policy, policy.EvalResult) { + base := resolvePolicyForCwd(d, sess.PolicyHash, cwd) + if base == nil { + return nil, policy.EvalResult{} + } + layers := groupPolicyLayers(d, sess) + result := policy.EvaluateLayered(base, layers, policy.EvalRequest{Tool: tool, Input: input}) + return base, result +} + +func groupPolicyLayers(d Deps, sess storage.Session) []policy.Layer { + if d.AgentlockHome == "" { + return nil + } + path := filepath.Join(d.AgentlockHome, groupPolicyFileName) + bundle, err := loadGroupPolicyFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + log.Printf("group policy: load %s: %v", path, err) + return nil + } + groups := append([]string(nil), sess.Groups...) + if u, ok := bundle.Users[sess.UserID]; ok { + groups = append(groups, u.Groups...) + } + groups = dedupeStrings(groups) + ordered := expandGroupOrder(bundle.Groups, groups) + layers := make([]policy.Layer, 0, len(ordered)+1) + for _, name := range ordered { + spec := bundle.Groups[name] + p := compileLayerPolicy("group:"+name, spec.Gates) + if p != nil { + layers = append(layers, policy.Layer{Name: "group:" + name, Policy: p}) + } + } + if u, ok := bundle.Users[sess.UserID]; ok && len(u.Gates) > 0 { + if p := compileLayerPolicy("user:"+sess.UserID, u.Gates); p != nil { + layers = append(layers, policy.Layer{Name: "user:" + sess.UserID, Policy: p}) + } + } + return layers +} + +func loadGroupPolicyFile(path string) (*groupPolicyFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var out groupPolicyFile + if err := yaml.Unmarshal(data, &out); err != nil { + return nil, err + } + if out.Groups == nil { + out.Groups = map[string]groupSpec{} + } + if out.Users == nil { + out.Users = map[string]personalSpec{} + } + return &out, nil +} + +func compileLayerPolicy(source string, gates []map[string]any) *policy.Policy { + if len(gates) == 0 { + return nil + } + copied := make([]map[string]any, 0, len(gates)) + for _, g := range gates { + next := map[string]any{} + for k, v := range g { + next[k] = v + } + if _, ok := next["source"]; !ok { + next["source"] = source + } + copied = append(copied, next) + } + data, err := yaml.Marshal(policyDoc{Version: 1, Mode: "enforce", Gates: copied}) + if err != nil { + log.Printf("group policy: marshal layer %s gates=%d: %v", source, len(copied), err) + return nil + } + p, err := policy.LoadBytes(data) + if err != nil { + log.Printf("group policy: compile layer %s gates=%d: %v", source, len(copied), err) + return nil + } + return p +} + +func expandGroupOrder(groups map[string]groupSpec, roots []string) []string { + var out []string + visiting := map[string]bool{} + visited := map[string]bool{} + var visit func(string) + visit = func(name string) { + name = strings.TrimSpace(name) + if name == "" || visited[name] || visiting[name] { + return + } + spec, ok := groups[name] + if !ok { + return + } + visiting[name] = true + for _, parent := range spec.Inherits { + visit(parent) + } + visiting[name] = false + visited[name] = true + out = append(out, name) + } + for _, root := range roots { + visit(root) + } + return out +} + +func dedupeStrings(in []string) []string { + out := make([]string, 0, len(in)) + seen := map[string]struct{}{} + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +func storagePolicyTrace(in []policy.TraceItem) []storage.PolicyTraceItem { + out := make([]storage.PolicyTraceItem, 0, len(in)) + for _, item := range in { + out = append(out, storage.PolicyTraceItem{ + Layer: item.Layer, + Source: item.Source, + RuleID: item.RuleID, + Verdict: item.Verdict, + Precedence: item.Precedence, + Priority: item.Priority, + }) + } + return out +} diff --git a/control-plane/internal/api/hooks_claude.go b/control-plane/internal/api/hooks_claude.go index e33a8c1..49308e4 100644 --- a/control-plane/internal/api/hooks_claude.go +++ b/control-plane/internal/api/hooks_claude.go @@ -15,7 +15,6 @@ import ( "net/http" "time" - "github.com/openagentlock/openagentlock/control-plane/internal/policy" "github.com/openagentlock/openagentlock/control-plane/internal/storage" ) @@ -66,15 +65,11 @@ func claudePreToolUseHandler(d Deps) http.HandlerFunc { return } - evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd) + evalPolicy, result := evaluatePolicyForSession(d, sess, in.Cwd, in.ToolName, in.ToolInput) if evalPolicy == nil { writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded") return } - result := evalPolicy.Evaluate(policy.EvalRequest{ - Tool: in.ToolName, - Input: in.ToolInput, - }) var origVerdict, mode string result, mode, origVerdict = applyDaemonModeOverride(result) @@ -111,6 +106,7 @@ func claudePreToolUseHandler(d Deps) http.HandlerFunc { Verdict: origVerdict, MonitorMatch: monitorMatch, MatcherInput: ledgerMatcherInput(in.ToolInput), + PolicyTrace: storagePolicyTrace(result.Trace), PayloadHash: payloadHash[:], }); err != nil { writeError(w, http.StatusInternalServerError, "ledger_error", err.Error()) diff --git a/control-plane/internal/api/hooks_codex.go b/control-plane/internal/api/hooks_codex.go index b705f8a..f3e4310 100644 --- a/control-plane/internal/api/hooks_codex.go +++ b/control-plane/internal/api/hooks_codex.go @@ -20,7 +20,6 @@ import ( "net/http" "time" - "github.com/openagentlock/openagentlock/control-plane/internal/policy" "github.com/openagentlock/openagentlock/control-plane/internal/storage" ) @@ -57,15 +56,11 @@ func codexPreToolUseHandler(d Deps) http.HandlerFunc { return } - evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd) + evalPolicy, result := evaluatePolicyForSession(d, sess, in.Cwd, in.ToolName, in.ToolInput) if evalPolicy == nil { writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded") return } - result := evalPolicy.Evaluate(policy.EvalRequest{ - Tool: in.ToolName, - Input: in.ToolInput, - }) var origVerdict, mode string result, mode, origVerdict = applyDaemonModeOverride(result) @@ -103,6 +98,7 @@ func codexPreToolUseHandler(d Deps) http.HandlerFunc { Verdict: origVerdict, MonitorMatch: monitorMatch, MatcherInput: ledgerMatcherInput(in.ToolInput), + PolicyTrace: storagePolicyTrace(result.Trace), PayloadHash: payloadHash[:], }); err != nil { writeError(w, http.StatusInternalServerError, "ledger_error", err.Error()) diff --git a/control-plane/internal/api/hooks_cursor.go b/control-plane/internal/api/hooks_cursor.go index a252bfe..1c49469 100644 --- a/control-plane/internal/api/hooks_cursor.go +++ b/control-plane/internal/api/hooks_cursor.go @@ -30,7 +30,6 @@ import ( "sync" "time" - "github.com/openagentlock/openagentlock/control-plane/internal/policy" "github.com/openagentlock/openagentlock/control-plane/internal/storage" ) @@ -214,15 +213,11 @@ func cursorGateHandler(d Deps, eventName string, kind cursorDedupeKind) http.Han return } - evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd) + evalPolicy, result := evaluatePolicyForSession(d, sess, in.Cwd, in.ToolName, in.ToolInput) if evalPolicy == nil { writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded") return } - result := evalPolicy.Evaluate(policy.EvalRequest{ - Tool: in.ToolName, - Input: in.ToolInput, - }) var origVerdict, mode string result, mode, origVerdict = applyDaemonModeOverride(result) @@ -262,6 +257,7 @@ func cursorGateHandler(d Deps, eventName string, kind cursorDedupeKind) http.Han Verdict: origVerdict, MonitorMatch: monitorMatch, MatcherInput: ledgerMatcherInput(in.ToolInput), + PolicyTrace: storagePolicyTrace(result.Trace), PayloadHash: payloadHash[:], }); err != nil { writeError(w, http.StatusInternalServerError, "ledger_error", err.Error()) @@ -500,7 +496,7 @@ func cursorBeforeShellHandler(d Deps) http.HandlerFunc { return } - evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd) + evalPolicy, result := evaluatePolicyForSession(d, sess, in.Cwd, "Shell", map[string]any{"command": in.Command}) if evalPolicy == nil { // No policy → can't evaluate, fail-open. preToolUse already // landed (or also failed-open), so this is a safe default. @@ -508,11 +504,6 @@ func cursorBeforeShellHandler(d Deps) http.HandlerFunc { return } - result := evalPolicy.Evaluate(policy.EvalRequest{ - Tool: "Shell", - Input: map[string]any{"command": in.Command}, - }) - result, _, _ = applyDaemonModeOverride(result) // No ledger append — preToolUse owns the audit trail for this diff --git a/control-plane/internal/api/hooks_gemini.go b/control-plane/internal/api/hooks_gemini.go index 4306235..0c06966 100644 --- a/control-plane/internal/api/hooks_gemini.go +++ b/control-plane/internal/api/hooks_gemini.go @@ -33,7 +33,6 @@ import ( "net/http" "time" - "github.com/openagentlock/openagentlock/control-plane/internal/policy" "github.com/openagentlock/openagentlock/control-plane/internal/storage" ) @@ -81,15 +80,11 @@ func geminiPreToolUseHandler(d Deps) http.HandlerFunc { return } - evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd) + evalPolicy, result := evaluatePolicyForSession(d, sess, in.Cwd, in.ToolName, in.ToolInput) if evalPolicy == nil { writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded") return } - result := evalPolicy.Evaluate(policy.EvalRequest{ - Tool: in.ToolName, - Input: in.ToolInput, - }) var origVerdict, mode string result, mode, origVerdict = applyDaemonModeOverride(result) @@ -123,6 +118,7 @@ func geminiPreToolUseHandler(d Deps) http.HandlerFunc { Verdict: origVerdict, MonitorMatch: monitorMatch, MatcherInput: ledgerMatcherInput(in.ToolInput), + PolicyTrace: storagePolicyTrace(result.Trace), PayloadHash: payloadHash[:], }); err != nil { writeError(w, http.StatusInternalServerError, "ledger_error", err.Error()) diff --git a/control-plane/internal/api/session.go b/control-plane/internal/api/session.go index 43bd169..5057fb8 100644 --- a/control-plane/internal/api/session.go +++ b/control-plane/internal/api/session.go @@ -130,6 +130,12 @@ func sessionRotateHandler(d Deps) http.HandlerFunc { updated.Signer = req.Signer updated.SignerPubKey = req.SignerPubKey updated.ExpiresAt = now.Add(sessionTTL) + if trimmed := strings.TrimSpace(req.UserID); trimmed != "" { + updated.UserID = trimmed + } + if req.Groups != nil { + updated.Groups = dedupeStrings(req.Groups) + } if err := d.Store.UpdateSession(r.Context(), updated); err != nil { writeError(w, http.StatusInternalServerError, "storage_error", err.Error()) return @@ -187,12 +193,14 @@ func splitTrim(s string) []string { } type sessionStartRequest struct { - PolicyHash string `json:"policy_hash"` - SessionPubKey string `json:"session_pubkey"` - Signer string `json:"signer"` - SignerPubKey string `json:"signer_pubkey"` - Attestation string `json:"attestation"` - Harness string `json:"harness,omitempty"` + PolicyHash string `json:"policy_hash"` + SessionPubKey string `json:"session_pubkey"` + Signer string `json:"signer"` + SignerPubKey string `json:"signer_pubkey"` + Attestation string `json:"attestation"` + Harness string `json:"harness,omitempty"` + UserID string `json:"user_id,omitempty"` + Groups []string `json:"groups,omitempty"` } // current accepted signer kinds. Mirrors docs/guide/signers.md. "none" is valid on @@ -256,6 +264,8 @@ func createSessionHandler(d Deps) http.HandlerFunc { Signer: req.Signer, SignerPubKey: req.SignerPubKey, Harness: strings.TrimSpace(req.Harness), + UserID: strings.TrimSpace(req.UserID), + Groups: dedupeStrings(req.Groups), } if err := d.Store.CreateSession(r.Context(), s); err != nil { writeError(w, http.StatusInternalServerError, "storage_error", err.Error()) diff --git a/control-plane/internal/policy/policy.go b/control-plane/internal/policy/policy.go index 44a5fa8..8793c90 100644 --- a/control-plane/internal/policy/policy.go +++ b/control-plane/internal/policy/policy.go @@ -37,13 +37,15 @@ type rawDef struct { } type rawGate struct { - ID string `yaml:"id"` - Source string `yaml:"source,omitempty"` - Mode string `yaml:"mode,omitempty"` - Severity string `yaml:"severity,omitempty"` - Disabled bool `yaml:"disabled,omitempty"` - Match rawMatch `yaml:"match"` - Evaluate []rawEval `yaml:"evaluate"` + ID string `yaml:"id"` + Source string `yaml:"source,omitempty"` + Mode string `yaml:"mode,omitempty"` + Severity string `yaml:"severity,omitempty"` + Disabled bool `yaml:"disabled,omitempty"` + Precedence string `yaml:"precedence,omitempty"` + Priority int `yaml:"priority,omitempty"` + Match rawMatch `yaml:"match"` + Evaluate []rawEval `yaml:"evaluate"` } type rawMatch struct { @@ -98,11 +100,13 @@ type evalEntry struct { } type Gate struct { - ID string - Mode string // monitor | enforce — inherits Policy.Mode if empty - Disabled bool // true = skip this gate during evaluation - Source string // daemon | registry | per-repo: - Match Matcher + ID string + Mode string // monitor | enforce — inherits Policy.Mode if empty + Disabled bool // true = skip this gate during evaluation + Source string // daemon | registry | group | user | per-repo: + Precedence string // empty = deny-overrides; priority = compare same rule id by Priority + Priority int + Match Matcher // Evals is the compiled evaluate[] pipeline. Each entry carries the // evaluator plus its optional `nudge:` hint; the firing entry's nudge // gets propagated into EvalResult on a deny verdict. @@ -428,6 +432,27 @@ type EvalResult struct { // hint. The API layer (applyDaemonModeOverride) is responsible for // clearing this when the agent is being allowed to proceed. Nudge string + Trace []TraceItem + // Source/Precedence/Priority describe the firing gate. They are + // duplicated into TraceItem for layered evaluation and kept here so + // callers that evaluate one policy can still ledger the source. + Source string + Precedence string + Priority int +} + +type TraceItem struct { + Layer string `json:"layer,omitempty"` + Source string `json:"source,omitempty"` + RuleID string `json:"rule_id"` + Verdict string `json:"verdict"` + Precedence string `json:"precedence,omitempty"` + Priority int `json:"priority,omitempty"` +} + +type Layer struct { + Name string + Policy *Policy } // Load parses YAML into a compiled Policy. Returns an error for unknown @@ -481,12 +506,14 @@ func loadBytesWithSource(buf []byte, source, defaultMode string) (*Policy, error gateSource = source } gates = append(gates, Gate{ - ID: rg.ID, - Mode: rg.Mode, - Disabled: rg.Disabled, - Source: gateSource, - Match: m, - Evals: evals, + ID: rg.ID, + Mode: rg.Mode, + Disabled: rg.Disabled, + Source: gateSource, + Precedence: rg.Precedence, + Priority: rg.Priority, + Match: m, + Evals: evals, }) } @@ -610,6 +637,16 @@ func (p *Policy) Evaluate(req EvalRequest) EvalResult { MonitorMatch: true, OriginalVerdict: verdict, Nudge: nudge, + Trace: []TraceItem{{ + Source: g.Source, + RuleID: g.ID, + Verdict: verdict, + Precedence: g.Precedence, + Priority: g.Priority, + }}, + Source: g.Source, + Precedence: g.Precedence, + Priority: g.Priority, } } // Only carry the nudge through on a deny verdict; an allow @@ -624,6 +661,16 @@ func (p *Policy) Evaluate(req EvalRequest) EvalResult { Reason: reason, OriginalVerdict: verdict, Nudge: resultNudge, + Trace: []TraceItem{{ + Source: g.Source, + RuleID: g.ID, + Verdict: verdict, + Precedence: g.Precedence, + Priority: g.Priority, + }}, + Source: g.Source, + Precedence: g.Precedence, + Priority: g.Priority, } } return EvalResult{ @@ -634,6 +681,97 @@ func (p *Policy) Evaluate(req EvalRequest) EvalResult { } } +func EvaluateLayered(base *Policy, layers []Layer, req EvalRequest) EvalResult { + if base == nil { + return EvalResult{Verdict: "allow", RuleID: "default", Reason: "no policy loaded", OriginalVerdict: "allow"} + } + results := make([]EvalResult, 0, len(layers)+1) + baseResult := base.Evaluate(req) + if baseResult.RuleID != "default" { + baseResult.Trace = stampTraceLayer(baseResult.Trace, "daemon") + results = append(results, baseResult) + } + for _, layer := range layers { + if layer.Policy == nil { + continue + } + r := layer.Policy.Evaluate(req) + if r.RuleID == "default" { + continue + } + r.Trace = stampTraceLayer(r.Trace, layer.Name) + results = append(results, r) + } + if len(results) == 0 { + return baseResult + } + effective := collapsePriorityConflicts(results) + chosen := effective[0] + for _, r := range effective { + if r.OriginalVerdict == "deny" { + chosen = r + break + } + } + chosen.Trace = flattenTrace(results) + if chosen.Reason == "" { + chosen.Reason = "matched layered policy" + } + return chosen +} + +func stampTraceLayer(items []TraceItem, layer string) []TraceItem { + out := append([]TraceItem(nil), items...) + for i := range out { + if out[i].Layer == "" { + out[i].Layer = traceLayer(out[i], layer) + } + } + return out +} + +func traceLayer(item TraceItem, fallback string) string { + if strings.HasPrefix(item.Source, "per-repo:") { + return item.Source + } + return fallback +} + +func flattenTrace(results []EvalResult) []TraceItem { + var out []TraceItem + for _, r := range results { + out = append(out, r.Trace...) + } + return out +} + +func collapsePriorityConflicts(results []EvalResult) []EvalResult { + bestByRule := map[string]int{} + drop := map[int]struct{}{} + for i, r := range results { + if r.Precedence != "priority" { + continue + } + if prior, ok := bestByRule[r.RuleID]; ok { + if r.Priority >= results[prior].Priority { + drop[prior] = struct{}{} + bestByRule[r.RuleID] = i + } else { + drop[i] = struct{}{} + } + continue + } + bestByRule[r.RuleID] = i + } + out := make([]EvalResult, 0, len(results)) + for i, r := range results { + if _, ok := drop[i]; !ok { + out = append(out, r) + } + } + return out +} + func gateMatches(g Gate, req EvalRequest) bool { return matcherMatches(g.Match, req) } diff --git a/control-plane/internal/policy/policy_test.go b/control-plane/internal/policy/policy_test.go index 9a8a1fc..f3878f6 100644 --- a/control-plane/internal/policy/policy_test.go +++ b/control-plane/internal/policy/policy_test.go @@ -207,6 +207,99 @@ func TestEvaluate_FirstMatchWins(t *testing.T) { } } +func TestEvaluateLayered_DenyOverridesPersonalAllow(t *testing.T) { + base, _ := Load(strings.NewReader(`version: 1 +mode: enforce +gates: + - id: base.safe + match: { tool: Bash, command_regex: '^echo ' } + evaluate: + - kind: always + action: allow +`)) + group, _ := LoadBytes([]byte(`version: 1 +mode: enforce +gates: + - id: group.secret-read + source: group:compliance + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: deny +`)) + user, _ := LoadBytes([]byte(`version: 1 +mode: enforce +gates: + - id: user.secret-read + source: user:alice + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: allow +`)) + got := EvaluateLayered(base, []Layer{{Name: "group:compliance", Policy: group}, {Name: "user:alice", Policy: user}}, EvalRequest{ + Tool: "Bash", + Input: map[string]any{"command": "cat secret.txt"}, + }) + if got.Verdict != "deny" || got.RuleID != "group.secret-read" { + t.Fatalf("got verdict=%q rule=%q, want group deny", got.Verdict, got.RuleID) + } + if len(got.Trace) != 2 { + t.Fatalf("trace len = %d, want 2: %+v", len(got.Trace), got.Trace) + } +} + +func TestEvaluateLayered_PriorityPrecedenceCanChooseAllow(t *testing.T) { + base, _ := Load(strings.NewReader(`version: 1 +mode: enforce +gates: + - id: base.never + match: { tool: Bash, command_regex: '^never$' } + evaluate: + - kind: always + action: deny +`)) + lowDeny, _ := LoadBytes([]byte(`version: 1 +mode: enforce +gates: + - id: shared.net + source: group:default + precedence: priority + priority: 10 + match: + tool: Bash + command_regex: '^curl ' + evaluate: + - kind: always + action: deny +`)) + highAllow, _ := LoadBytes([]byte(`version: 1 +mode: enforce +gates: + - id: shared.net + source: group:red-team + precedence: priority + priority: 20 + match: + tool: Bash + command_regex: '^curl ' + evaluate: + - kind: always + action: allow +`)) + got := EvaluateLayered(base, []Layer{{Name: "group:default", Policy: lowDeny}, {Name: "group:red-team", Policy: highAllow}}, EvalRequest{ + Tool: "Bash", + Input: map[string]any{"command": "curl https://example.com"}, + }) + if got.Verdict != "allow" || got.RuleID != "shared.net" { + t.Fatalf("got verdict=%q rule=%q, want higher-priority allow", got.Verdict, got.RuleID) + } +} + const pkgInstallYAML = ` version: 1 mode: enforce @@ -408,7 +501,7 @@ func TestEvaluate_PinTofu_NoServerFieldSkips(t *testing.T) { // tool_prefix matches but no mcp_server in input → evaluator skips → // gate falls through to default. v := p.Evaluate(EvalRequest{ - Tool: "mcp__noop__x", + Tool: "mcp__noop__x", Input: map[string]any{}, }) if v.Verdict != "allow" || v.RuleID != "default" { diff --git a/control-plane/internal/storage/memory.go b/control-plane/internal/storage/memory.go index 1835a88..50ca787 100644 --- a/control-plane/internal/storage/memory.go +++ b/control-plane/internal/storage/memory.go @@ -40,7 +40,9 @@ type Session struct { // (claude-code, cursor, tui, system, ...). Set at session-create from // the incoming hook body or the request. Blank on pre-harness-aware // sessions; UI renders those as "unknown". - Harness string `json:"harness,omitempty"` + Harness string `json:"harness,omitempty"` + UserID string `json:"user_id,omitempty"` + Groups []string `json:"groups,omitempty"` } type AppendInput struct { @@ -61,10 +63,20 @@ type AppendInput struct { // a hard "deny" so monitor mode looks like an IDS, not an IPS. MonitorMatch bool MatcherInput map[string]string + PolicyTrace []PolicyTraceItem PayloadHash []byte Sig []byte } +type PolicyTraceItem struct { + Layer string `json:"layer,omitempty"` + Source string `json:"source,omitempty"` + RuleID string `json:"rule_id"` + Verdict string `json:"verdict"` + Precedence string `json:"precedence,omitempty"` + Priority int `json:"priority,omitempty"` +} + type LedgerEntry struct { Seq uint64 `json:"seq"` TS time.Time `json:"ts"` @@ -76,6 +88,7 @@ type LedgerEntry struct { RuleID string `json:"rule_id,omitempty"` Verdict string `json:"verdict,omitempty"` MonitorMatch bool `json:"monitor_match,omitempty"` + PolicyTrace []PolicyTraceItem `json:"policy_trace,omitempty"` PayloadHash string `json:"payload_hash"` Sig string `json:"sig"` LeafHash [32]byte `json:"-"` @@ -376,6 +389,7 @@ func (m *Memory) AppendLedger(_ context.Context, in AppendInput) (LedgerEntry, e RuleID: in.RuleID, Verdict: in.Verdict, MonitorMatch: in.MonitorMatch, + PolicyTrace: append([]PolicyTraceItem(nil), in.PolicyTrace...), PayloadHash: hex.EncodeToString(in.PayloadHash), Sig: hex.EncodeToString(in.Sig), LeafHash: leaf, diff --git a/docs/architecture/group-policy.md b/docs/architecture/group-policy.md new file mode 100644 index 0000000..5853745 --- /dev/null +++ b/docs/architecture/group-policy.md @@ -0,0 +1,126 @@ +# Group Policy + +OpenAgentLock supports a filesystem-backed group-policy bundle at `AGENTLOCK_HOME/group-policy.yaml`. The daemon combines that bundle with the session's optional `user_id` and `groups` fields to evaluate policy per session. + +## Goals + +Organizations need policy layers that are broader than one repo but narrower than the daemon's global policy. A compliance group may deny secret reads for everyone, while a red-team group may carry carefully reviewed allow rules. The evaluator must make these conflicts predictable and auditable. + +## Identity Source + +The current slice accepts identity on the session object: + +```json +{ + "user_id": "alice", + "groups": ["default", "red-team"] +} +``` + +This is intentionally a carrier, not the final identity source. OAL-21 owns OIDC/LDAP/group-claim resolution. Once that lands, the auth layer should populate the same session fields from trusted identity claims. Admin overrides can keep writing the same group-policy bundle. + +## Schema + +`AGENTLOCK_HOME/group-policy.yaml` is described by `control-plane/api/group-policy.schema.json`: + +```yaml +version: 1 +groups: + default: + gates: + - id: group.default.secret-read + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: deny + + red-team: + inherits: [default] + gates: + - id: shared.net + precedence: priority + priority: 20 + match: + tool: Bash + command_regex: '^curl ' + evaluate: + - kind: always + action: allow + +users: + alice: + groups: [red-team] + gates: + - id: user.alice.local-deny + match: + tool: Bash + command_regex: '^open-prod-console' + evaluate: + - kind: always + action: deny +``` + +Group and user `gates` use the same gate schema as daemon policy and `openagentlock/rules` gate blocks. If `source` is omitted, the daemon stamps `group:` or `user:`. + +## Layering + +Evaluation order is: + +1. Daemon built-in or `AGENTLOCK_POLICY` +2. Registry-installed gates, carried in the daemon policy with `source: registry:` +3. Group policies, one layer per resolved group +4. Personal user overlay +5. Repo-local `.agentlock.yaml` from OAL-72 + +Within a single layer, the existing policy behavior applies: first matching gate wins. + +Across layers, the default is deny-overrides. The daemon evaluates every layer that contributes a verdict. If any contributing layer denies, the final verdict is deny. This means personal allow rules cannot bypass group denies by default. + +For multiple top-level groups, the daemon iterates `session.groups` in the exact order provided. For each group it appends that group's resolved ancestry from root to child, then the group itself, while skipping duplicates so each resolved group appears once. Example: if `session.groups` is `[compliance, red-team]` and `red-team` inherits `default`, the resolved group layer order is `compliance`, `default`, `red-team`. + +## Priority Conflicts + +Operators can opt a shared gate id into priority-based resolution: + +```yaml +id: shared.net +precedence: priority +priority: 20 +``` + +When multiple layers match the same `id` and those gates use `precedence: priority`, the highest `priority` match is kept for that id before deny-overrides runs. This is deliberately explicit so allow-direction exceptions cannot happen by accident. + +### Priority Resolution Details + +`first matching gate wins` applies only within a single layer. If two gates in the same layer share an id and both declare `precedence: priority`, only the first matching gate in that layer contributes a verdict. + +Across layers, `precedence: priority` compares the numeric `priority` field only when competing matches for the same id declare `precedence: priority`. If any matching gate for that id does not opt in, priority-based resolution is skipped for that non-priority match and standard deny-overrides still applies. This keeps allow-direction exceptions explicit and local to the shared id that opted into priority handling. + +## Inheritance + +Groups are parallel by default. A group inherits only when it declares `inherits: [...]`. Parents are evaluated before the child. Cycles are ignored after the first visit to avoid infinite inheritance. + +## Conflict Reporting + +Ledger entries include `policy_trace`, a compact list of contributing layer decisions: + +```json +[ + {"layer":"group:compliance","rule_id":"shared.secret","verdict":"deny"}, + {"layer":"user:alice","rule_id":"user.secret-allow","verdict":"allow"} +] +``` + +The dashboard event detail renders this chain so an operator can see both the winning deny and the losing allow. + +## Storage And Performance + +This slice reads `group-policy.yaml` on demand from `AGENTLOCK_HOME`. The intended cache key is `(user_id, group_set, group_policy_hash, live_policy_hash, repo_policy_hash)`. A later cache should invalidate on group-policy mtime/size changes and on live policy swaps. + +## Out Of Scope + +- OIDC/LDAP group claim resolution: OAL-21. +- Dashboard editor for group policies: dashboard epic. +- Private registry management for org policy bundles. diff --git a/docs/guide/policies.md b/docs/guide/policies.md index c89d4bf..f59e293 100644 --- a/docs/guide/policies.md +++ b/docs/guide/policies.md @@ -68,6 +68,29 @@ gates: The daemon walks upward from `cwd` and uses the nearest `.agentlock.yaml`. Sibling repos are unaffected. Because cloned repos are not trusted, repo-local policy is additive by default: new deny-producing gates apply immediately, but disabled gates, same-id overrides, and `always: allow` content cannot weaken daemon policy without an operator approval flow. See [Per-Repo Policy](../architecture/per-repo-policy.md) for the full trust model and precedence chain. +## Group policy + +Multi-user deployments can add `AGENTLOCK_HOME/group-policy.yaml` to layer group and personal gates over the daemon policy. Sessions may carry optional `user_id` and `groups` fields that determine which policy gates apply to each user. Today those fields can be supplied by the session API / CLI; directory-backed population belongs with the auth integration. + +```yaml +version: 1 +groups: + compliance: + gates: + - id: group.secret-read + match: + tool: Bash + command_regex: '^cat secret' + evaluate: + - kind: always + action: deny +users: + alice: + groups: [compliance] +``` + +Across daemon, registry, group, user, and repo layers, deny-overrides is the default. A shared gate id may opt into `precedence: priority` plus `priority: ` when an operator wants highest-priority-wins for that id. Ledger entries include `policy_trace` so the dashboard can show which layers allowed or denied a call. See [Group Policy](../architecture/group-policy.md). + ### Pin a private registry too Most teams want a few internal-only rules alongside the upstream catalog. Any Git repo with the same `rules//rule.yaml` layout works: diff --git a/docs/reference/api.md b/docs/reference/api.md index 38db7d1..3997320 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -41,6 +41,8 @@ Response fields: - `nudge` — optional, string. Present only when the matched rule defined a `nudge:` hint **and** the final verdict is `deny`. Allow / monitor-suppressed paths drop it. See [Policies → Nudges](../guide/policies.md#nudges). - `ledger_seq` — sequence number of the corresponding ledger leaf +The corresponding ledger entry may include `policy_trace`, listing every daemon / registry / group / user / repo layer that contributed a verdict. + `GET /v1/policy/view` Returns the live policy as dashboard-friendly gates. Each gate includes `source`: `daemon` for built-in or direct policy-file gates, `registry:` for rules installed from a rules registry, or `per-repo:` for repo-local gates. @@ -63,6 +65,8 @@ The first time a new MCP server fingerprint is seen, it's queued for pinning. Ac `POST /v1/sessions/create` — bootstrap a new short-lived session, signed by the host CLI's long-lived key +Session create/rotate accepts optional `user_id` and `groups` fields. These are used by `AGENTLOCK_HOME/group-policy.yaml` until OIDC/LDAP group-claim resolution lands. + `GET /v1/sessions/:id` — session metadata `POST /v1/sessions/heartbeat` — keep a session alive diff --git a/docs/status.md b/docs/status.md index 609019b..2ee6afa 100644 --- a/docs/status.md +++ b/docs/status.md @@ -82,6 +82,6 @@ Flip to `mode: enforce` at the top of your policy file when you've reviewed acti | MCP observation via lifecycle hooks (Claude Code, Cursor, Cline, Gemini CLI, OpenCode) | Shipped on the hook side; OpenCode does not currently fire the pre-tool hook for MCP | | MCP fingerprint pinning (`/v1/mcp/pin`) | Shipped | | OIDC SSO + RBAC + LDAP | Not yet implemented | -| Group / scoped policy with inheritance | Not yet implemented | +| Group / scoped policy with inheritance | Shipped — filesystem-backed `group-policy.yaml`, deny-overrides, explicit priority conflict handling; OIDC group source remains under auth epic | | Federated deployment (per-dev daemons + central control plane) | Not yet implemented | | Signed PDF audit report | Not yet implemented |