Skip to content

Commit 8405cd8

Browse files
authored
Add repo-scoped agentlock policy (#76)
1 parent b89fca7 commit 8405cd8

17 files changed

Lines changed: 544 additions & 35 deletions

File tree

cli/src/commands/rules.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ interface RuleYAML {
6161
gate?: Record<string, unknown>;
6262
}
6363

64+
interface RepoAgentlockYAML {
65+
version?: number;
66+
extends?: string[];
67+
gates?: Record<string, unknown>[];
68+
}
69+
6470
function registriesPath(): string {
6571
return join(agentlockHome(), "registries.json");
6672
}
@@ -340,7 +346,7 @@ export async function runRulesSearch(opts: { query?: string } & RulesCommonOptio
340346
}
341347

342348
export async function runRulesInstall(
343-
opts: { spec: string; replace?: boolean } & RulesCommonOptions,
349+
opts: { spec: string; replace?: boolean; repo?: boolean } & RulesCommonOptions,
344350
) {
345351
const resolved = resolveRule(opts.spec);
346352
if (!resolved.rule.gate) {
@@ -355,7 +361,23 @@ export async function runRulesInstall(
355361
const gateBlock = {
356362
...(resolved.rule.gate as Record<string, unknown>),
357363
id: resolved.rule.id,
364+
source: `registry:${resolved.registryId}`,
358365
};
366+
if (opts.repo) {
367+
installGateIntoRepoAgentlock(gateBlock, opts.replace);
368+
if (opts.json) {
369+
process.stdout.write(
370+
JSON.stringify(
371+
{ installed: resolved.ruleId, registry: resolved.registryId, file: ".agentlock.yaml" },
372+
null,
373+
2,
374+
) + "\n",
375+
);
376+
return;
377+
}
378+
process.stdout.write(`installed ${resolved.ruleId} into .agentlock.yaml\n`);
379+
return;
380+
}
359381
const yamlBody = dumpYAML(gateBlock);
360382
const client = apiClient(opts.url);
361383
const res = await client.installGateYAML(yamlBody, !!opts.replace);
@@ -370,6 +392,43 @@ export async function runRulesInstall(
370392
);
371393
}
372394

395+
function installGateIntoRepoAgentlock(gateBlock: Record<string, unknown>, replace?: boolean): void {
396+
const path = join(process.cwd(), ".agentlock.yaml");
397+
let doc: RepoAgentlockYAML = { version: 1, gates: [] };
398+
if (existsSync(path)) {
399+
const loaded = loadRuleLikeDocument(readFileSync(path, "utf8"));
400+
doc = {
401+
version: typeof loaded.version === "number" ? loaded.version : 1,
402+
extends: Array.isArray(loaded.extends) ? loaded.extends : undefined,
403+
gates: Array.isArray(loaded.gates) ? loaded.gates : [],
404+
};
405+
}
406+
if (!doc.version) doc.version = 1;
407+
if (!doc.gates) doc.gates = [];
408+
const id = String(gateBlock.id ?? "");
409+
const existing = doc.gates.findIndex((g) => g.id === id);
410+
if (existing >= 0 && !replace) {
411+
throw new Error(`gate ${id} already exists in .agentlock.yaml (use --replace to overwrite)`);
412+
}
413+
if (existing >= 0) {
414+
doc.gates[existing] = gateBlock;
415+
} else {
416+
doc.gates.push(gateBlock);
417+
}
418+
writeFileSync(path, dumpYAML(doc), { mode: 0o644 });
419+
}
420+
421+
function loadRuleLikeDocument(body: string): Record<string, unknown> {
422+
try {
423+
return requireYAML().parse(body) as Record<string, unknown>;
424+
} catch (err) {
425+
if ((err as { code?: string }).code === "MODULE_NOT_FOUND") {
426+
return JSON.parse(body) as Record<string, unknown>;
427+
}
428+
throw err;
429+
}
430+
}
431+
373432
export async function runRulesUninstall(opts: { id: string } & RulesCommonOptions) {
374433
const client = apiClient(opts.url);
375434
const res = await client.deleteGate(opts.id);

cli/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,10 +461,11 @@ rules
461461
"Install a rule into the local policy. Accepts a bare id or 'registryId:ruleId' to disambiguate.",
462462
)
463463
.option("--replace", "Overwrite an existing gate with the same id.")
464+
.option("--repo", "Write the rule into the current repo's .agentlock.yaml instead of daemon policy.")
464465
.option("--url <url>", "Control-plane base URL.")
465466
.option("--json", "Emit JSON instead of human output.", false)
466-
.action(async (ruleId: string, opts: { replace?: boolean; url?: string; json: boolean }) => {
467-
await runRulesInstall({ spec: ruleId, replace: opts.replace, url: opts.url, json: opts.json });
467+
.action(async (ruleId: string, opts: { replace?: boolean; repo?: boolean; url?: string; json: boolean }) => {
468+
await runRulesInstall({ spec: ruleId, replace: opts.replace, repo: opts.repo, url: opts.url, json: opts.json });
468469
});
469470

470471
rules

cli/tests/rules.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ describe("agentlock rules", () => {
170170
const body = captured[0]!.body as { yaml: string; replace: boolean };
171171
expect(body.replace).toBe(false);
172172
expect(body.yaml).toContain("rogue.destructive-bash");
173+
expect(body.yaml).toContain("registry:openagentlock-rules");
173174
expect(body.yaml).toContain("any_command_regex");
174175
expect(body.yaml).toContain("rm");
175176
},
@@ -198,6 +199,29 @@ describe("agentlock rules", () => {
198199
);
199200
});
200201

202+
test("install --repo writes registry rule gate into .agentlock.yaml", async () => {
203+
const repo = join(home, "repo");
204+
mkdirSync(repo, { recursive: true });
205+
const originalCwd = process.cwd();
206+
process.chdir(repo);
207+
try {
208+
await runRulesInstall({
209+
spec: "rogue.destructive-bash",
210+
repo: true,
211+
json: true,
212+
});
213+
} finally {
214+
process.chdir(originalCwd);
215+
}
216+
217+
const body = readFileSync(join(repo, ".agentlock.yaml"), "utf8");
218+
expect(body).toContain("version: 1");
219+
expect(body).toContain("rogue.destructive-bash");
220+
expect(body).toContain("registry:openagentlock-rules");
221+
expect(body).toContain("any_command_regex");
222+
expect(body).toContain("rm");
223+
});
224+
201225
test("install on missing rule throws", async () => {
202226
await expect(runRulesInstall({ spec: "nonexistent.id", url: "http://0" })).rejects.toThrow(
203227
/not found/,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface GateView {
2929
id: string;
3030
mode: string;
3131
disabled: boolean;
32+
source: string;
3233
tool?: string;
3334
tool_prefix?: string;
3435
any_command_regex?: string[];

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ function RulesTab() {
8484
<th style={{ width: 48 }}>on</th>
8585
<th>id</th>
8686
<th>mode</th>
87+
<th>source</th>
8788
<th>tool</th>
8889
<th>match</th>
8990
<th style={{ width: 96 }}>actions</th>
@@ -110,6 +111,7 @@ function RulesTab() {
110111
<td>
111112
<span className="oal-chip">{g.mode || "inherit"}</span>
112113
</td>
114+
<td className="font-mono text-muted text-[11px]">{g.source || "daemon"}</td>
113115
<td className="font-mono">{g.tool || g.tool_prefix || "—"}</td>
114116
<td className="font-mono text-muted text-[11px]">
115117
{g.any_command_regex && g.any_command_regex.length > 0
@@ -134,7 +136,7 @@ function RulesTab() {
134136
))
135137
) : (
136138
<tr>
137-
<td colSpan={6} className="text-center text-muted py-4">
139+
<td colSpan={7} className="text-center text-muted py-4">
138140
no rules
139141
</td>
140142
</tr>

control-plane/internal/api/gate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func gateCheckHandler(d Deps) http.HandlerFunc {
6666

6767
// Resolve the policy pinned to this session's hash; falls back to
6868
// live when the hash is unknown (e.g. registry not yet seeded).
69-
evalPolicy := resolvePolicy(d, sess.PolicyHash)
69+
evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, req.Cwd)
7070
if evalPolicy == nil {
7171
writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded")
7272
return

control-plane/internal/api/gate_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,145 @@ func TestGateCheck_DeniesDestructiveBash(t *testing.T) {
169169
}
170170
}
171171

172+
func TestGateCheck_AppliesRepoAgentlockOnlyInsideCwdTree(t *testing.T) {
173+
fx := newGateFixture(t, enforcePolicyYAML)
174+
repo := filepath.Join(fx.home, "repo")
175+
sibling := filepath.Join(fx.home, "sibling")
176+
if err := os.MkdirAll(filepath.Join(repo, "pkg"), 0o755); err != nil {
177+
t.Fatal(err)
178+
}
179+
if err := os.MkdirAll(sibling, 0o755); err != nil {
180+
t.Fatal(err)
181+
}
182+
if err := os.WriteFile(filepath.Join(repo, ".agentlock.yaml"), []byte(`
183+
version: 1
184+
gates:
185+
- id: repo.block-secret-print
186+
match:
187+
tool: Bash
188+
any_command_regex:
189+
- 'cat\s+secrets\.txt'
190+
evaluate:
191+
- kind: always
192+
action: deny
193+
`), 0o644); err != nil {
194+
t.Fatal(err)
195+
}
196+
197+
inside := fmt.Sprintf(`{
198+
"session_id": %q,
199+
"source": "codex",
200+
"tool": "Bash",
201+
"cwd": %q,
202+
"input": {"command": "cat secrets.txt"}
203+
}`, fx.sessionID, filepath.Join(repo, "pkg"))
204+
res, out := postGateCheck(t, fx.srv, inside)
205+
if res.StatusCode != http.StatusOK {
206+
t.Fatalf("inside status = %d", res.StatusCode)
207+
}
208+
if out["verdict"] != "deny" || out["rule_id"] != "repo.block-secret-print" {
209+
t.Fatalf("inside should hit repo rule, got %+v", out)
210+
}
211+
212+
outside := fmt.Sprintf(`{
213+
"session_id": %q,
214+
"source": "codex",
215+
"tool": "Bash",
216+
"cwd": %q,
217+
"input": {"command": "cat secrets.txt"}
218+
}`, fx.sessionID, sibling)
219+
res, out = postGateCheck(t, fx.srv, outside)
220+
if res.StatusCode != http.StatusOK {
221+
t.Fatalf("outside status = %d", res.StatusCode)
222+
}
223+
if out["verdict"] != "allow" || out["rule_id"] != "default" {
224+
t.Fatalf("sibling repo should not inherit repo rule, got %+v", out)
225+
}
226+
}
227+
228+
func TestGateCheck_IgnoresPermissiveRepoAgentlockUntilApproved(t *testing.T) {
229+
fx := newGateFixture(t, enforcePolicyYAML)
230+
repo := filepath.Join(fx.home, "repo")
231+
if err := os.MkdirAll(repo, 0o755); err != nil {
232+
t.Fatal(err)
233+
}
234+
if err := os.WriteFile(filepath.Join(repo, ".agentlock.yaml"), []byte(`
235+
version: 1
236+
gates:
237+
- id: rogue.destructive-bash
238+
disabled: true
239+
match:
240+
tool: Bash
241+
any_command_regex:
242+
- 'rm\s+-rf\b'
243+
evaluate:
244+
- kind: always
245+
action: allow
246+
`), 0o644); err != nil {
247+
t.Fatal(err)
248+
}
249+
250+
body := fmt.Sprintf(`{
251+
"session_id": %q,
252+
"source": "codex",
253+
"tool": "Bash",
254+
"cwd": %q,
255+
"input": {"command": "rm -rf /tmp/demo"}
256+
}`, fx.sessionID, repo)
257+
res, out := postGateCheck(t, fx.srv, body)
258+
if res.StatusCode != http.StatusOK {
259+
t.Fatalf("status = %d", res.StatusCode)
260+
}
261+
if out["verdict"] != "deny" || out["rule_id"] != "rogue.destructive-bash" {
262+
t.Fatalf("permissive repo override must not weaken daemon policy, got %+v", out)
263+
}
264+
}
265+
266+
func TestGateCheck_AppliesRepoGateWithConditionalDenyEvaluator(t *testing.T) {
267+
fx := newGateFixture(t, enforcePolicyYAML)
268+
repo := filepath.Join(fx.home, "repo")
269+
allowlist := filepath.Join(repo, "allowed.txt")
270+
if err := os.MkdirAll(repo, 0o755); err != nil {
271+
t.Fatal(err)
272+
}
273+
if err := os.WriteFile(allowlist, []byte("safe-package\n"), 0o644); err != nil {
274+
t.Fatal(err)
275+
}
276+
if err := os.WriteFile(filepath.Join(repo, ".agentlock.yaml"), []byte(fmt.Sprintf(`
277+
version: 1
278+
gates:
279+
- id: repo.npm-allowlist
280+
match:
281+
tool: Bash
282+
any_command_regex:
283+
- '^npm\s+install\s+'
284+
evaluate:
285+
- kind: allowlist
286+
list: %q
287+
on_hit: allow
288+
on_miss: deny
289+
- kind: always
290+
action: allow
291+
`, allowlist)), 0o644); err != nil {
292+
t.Fatal(err)
293+
}
294+
295+
body := fmt.Sprintf(`{
296+
"session_id": %q,
297+
"source": "codex",
298+
"tool": "Bash",
299+
"cwd": %q,
300+
"input": {"command": "npm install evil-package"}
301+
}`, fx.sessionID, repo)
302+
res, out := postGateCheck(t, fx.srv, body)
303+
if res.StatusCode != http.StatusOK {
304+
t.Fatalf("status = %d", res.StatusCode)
305+
}
306+
if out["verdict"] != "deny" || out["rule_id"] != "repo.npm-allowlist" {
307+
t.Fatalf("conditional deny repo gate should apply, got %+v", out)
308+
}
309+
}
310+
172311
func TestGateCheck_WritesLedgerEntry(t *testing.T) {
173312
fx := newGateFixture(t, enforcePolicyYAML)
174313

control-plane/internal/api/hooks_claude.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func claudePreToolUseHandler(d Deps) http.HandlerFunc {
6666
return
6767
}
6868

69-
evalPolicy := resolvePolicy(d, sess.PolicyHash)
69+
evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd)
7070
if evalPolicy == nil {
7171
writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded")
7272
return

control-plane/internal/api/hooks_codex.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func codexPreToolUseHandler(d Deps) http.HandlerFunc {
5757
return
5858
}
5959

60-
evalPolicy := resolvePolicy(d, sess.PolicyHash)
60+
evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd)
6161
if evalPolicy == nil {
6262
writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded")
6363
return

control-plane/internal/api/hooks_cursor.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func cursorGateHandler(d Deps, eventName string, kind cursorDedupeKind) http.Han
214214
return
215215
}
216216

217-
evalPolicy := resolvePolicy(d, sess.PolicyHash)
217+
evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd)
218218
if evalPolicy == nil {
219219
writeError(w, http.StatusServiceUnavailable, "policy_unavailable", "no policy loaded")
220220
return
@@ -500,7 +500,7 @@ func cursorBeforeShellHandler(d Deps) http.HandlerFunc {
500500
return
501501
}
502502

503-
evalPolicy := resolvePolicy(d, sess.PolicyHash)
503+
evalPolicy := resolvePolicyForCwd(d, sess.PolicyHash, in.Cwd)
504504
if evalPolicy == nil {
505505
// No policy → can't evaluate, fail-open. preToolUse already
506506
// landed (or also failed-open), so this is a safe default.

0 commit comments

Comments
 (0)