Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Full walkthrough at <https://openagentlock.github.io/OpenAgentLock/guide/getting

## Community rules registry

Need more gates than the five baseline ones? Browse the community catalog at <https://openagentlock.github.io/rules/> — secret reads, force-push to shared branches, network exfil, untrusted eval. Install with one command:
Need more gates than the thirteen that ship in the baseline? Browse the community catalog at <https://openagentlock.github.io/rules/> — network exfil host allowlists, package typosquat, broader persistence shapes, plus org-specific rules. Install with one command:

```bash
agentlock rules sync
Expand All @@ -72,7 +72,7 @@ For agents that need to **author** new rules from natural-language intent, see [
| `agentlock install` (Claude Code, Codex CLI, Cursor, Gemini CLI) | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
| `agentlock install --tier {unattested,software,totp}` | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
| `agentlock install` (OpenCode, Cline, Continue, VS Code Copilot) | ![not yet](https://img.shields.io/badge/-not%20yet-f59e0b?style=flat-square) |
| Five baseline gates in monitor mode | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
| Thirteen cross-harness baseline gates in enforce mode (no `rules install` needed) | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
| Tamper-evident Merkle ledger | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
| Local web dashboard | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
| Software + TOTP signers (with `signer enroll` + session mint) | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
Expand Down Expand Up @@ -111,16 +111,18 @@ Three languages, one repo:

See [Architecture overview](https://openagentlock.github.io/OpenAgentLock/architecture/overview/) for the why behind the split.

## Policy — registry-first
## Policy — baseline + registry

OpenAgentLock ships with a minimal first-boot policy (a single `rogue.destructive-bash` gate in monitor mode) so every session has *some* policy hash to attest against. Real coverage comes from the [openagentlock/rules](https://github.com/openagentlock/rules) registry — install whichever rules match your threat model:
OpenAgentLock ships a **thirteen-gate enforce-mode baseline** embedded in the daemon binary (source: [`control-plane/internal/policy/baseline.yaml`](./control-plane/internal/policy/baseline.yaml)). Fresh installs block destructive shell commands, supply-chain RCE shapes (`curl … | bash`, `eval $(curl …)`), reverse shells, secret/credential reads (`.env`, `.aws/credentials`, gcloud/Azure/Terraform state), defence evasion (`iptables -F`, `csrutil disable`, `history -c`), `chmod 777`, destructive `kubectl delete ns` / `helm uninstall`, force-push to shared branches, writes to `/etc/sudoers` / `~/.ssh/authorized_keys`, persistence appends to `~/.bashrc` / `~/.zshrc`, and cron/systemd-timer install — across **Claude Code, Codex, Cursor, Claude Desktop, and Gemini (via MCP)** without an `agentlock rules install` step. Each gate uses `any_of` arms covering `Bash` + `Shell` + `tool_prefix: mcp_` (catches both Claude/Cursor's `mcp__` double-underscore and Gemini's `mcp_` single-underscore wire shape) and, for write/edit gates, `Write` + `Edit` + `MultiEdit`. See [`docs/guide/policies.md`](./docs/guide/policies.md#first-boot-baseline-policy) for the full gate inventory and per-harness coverage matrix.

Layer org-specific or broader coverage on top via the [openagentlock/rules](https://github.com/openagentlock/rules) registry:

```bash
agentlock rules sync # tap the upstream registry
agentlock rules search exfil # browse by keyword
agentlock rules install rogue.destructive-bash # land a gate in the live policy
agentlock rules install exfil.curl-with-env
agentlock rules install rogue.secret-read
agentlock rules install rogue.net-egress # block unknown-host curl/wget
agentlock rules install supply-chain.npm-untrusted # deny installs from URL/git/tarball
agentlock rules install exfil.curl-with-env # catch $ENV_VAR exfil shapes
```

You can also tap a private registry (any Git repo with the same layout) for org-internal rules:
Expand Down
112 changes: 74 additions & 38 deletions cli/src/commands/mcp-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,18 @@ export async function runMcpProxy(argv: string[]): Promise<void> {
child.on("exit", (code, signal) => {
// Mirror the child's termination to our parent so Claude Desktop's
// server-died detection works the same as it would without the proxy.
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 0);
// Windows doesn't honor POSIX signals, so a process.kill with anything
// other than SIGTERM/SIGKILL is undefined behavior — fall back to a
// non-zero exit so the parent still sees an abnormal termination.
if (signal) {
if (process.platform === "win32") {
process.exit(1);
} else {
process.kill(process.pid, signal);
}
} else {
process.exit(code ?? 0);
}
});

// Track the in-flight tool/call ids so we can fire post-tool-use when
Expand All @@ -261,48 +271,74 @@ export async function runMcpProxy(argv: string[]): Promise<void> {
const pendingToolCalls = new Map<string, { name: string; toolUseId: string }>();

// Claude Desktop → us → child
//
// Lines from stdin are processed in order through a single Promise
// chain. The naive `process.stdin.on("data", async ...)` pattern lets
// two chunks that arrive close together race on `pendingToolCalls`
// and reorder writes to the child — the await-on-callPreToolUse for
// chunk N can resolve after chunk N+1's, flipping the verdict-vs-
// forward order. Serializing through `processingChain` keeps every
// line's effects (deny synthesis, Map insert, child.stdin.write) in
// arrival order.
const stdinBuf = new LineBuffer();
process.stdin.on("data", async (chunk: Buffer) => {
for (const line of stdinBuf.push(chunk)) {
let msg: JsonRpcMessage;
try {
msg = JSON.parse(line) as JsonRpcMessage;
} catch {
// Pass malformed lines through — the child will reject them and
// we don't want to silently drop messages we don't understand.
child.stdin.write(line + "\n");
continue;
}
if (msg.method === "tools/call" && msg.id !== undefined && msg.id !== null) {
const params = (msg.params ?? {}) as {
name?: string;
arguments?: unknown;
};
const { decision, reason } = await callPreToolUse(
daemonUrl,
parsed.serverName,
sessionId,
msg,
);
if (decision === "deny") {
// Short-circuit: respond directly to Claude, don't wake child.
process.stdout.write(JSON.stringify(denyResponseFor(msg.id, reason)) + "\n");
continue;
}
// Allow / ask → forward and remember the id so the response
// path can fire post-tool-use.
pendingToolCalls.set(String(msg.id), {
name: params.name ?? "<unknown>",
toolUseId: String(msg.id),
});
child.stdin.write(line + "\n");
continue;
let processingChain: Promise<void> = Promise.resolve();

async function processLine(line: string): Promise<void> {
let msg: JsonRpcMessage;
try {
msg = JSON.parse(line) as JsonRpcMessage;
} catch {
// Pass malformed lines through — the child will reject them and
// we don't want to silently drop messages we don't understand.
child.stdin.write(line + "\n");
return;
}
if (msg.method === "tools/call" && msg.id !== undefined && msg.id !== null) {
const params = (msg.params ?? {}) as {
name?: string;
arguments?: unknown;
};
const { decision, reason } = await callPreToolUse(
daemonUrl,
parsed.serverName,
sessionId,
msg,
);
if (decision === "deny") {
// Short-circuit: respond directly to Claude, don't wake child.
process.stdout.write(JSON.stringify(denyResponseFor(msg.id, reason)) + "\n");
return;
}
// Allow / ask → forward and remember the id so the response
// path can fire post-tool-use.
pendingToolCalls.set(String(msg.id), {
name: params.name ?? "<unknown>",
toolUseId: String(msg.id),
});
child.stdin.write(line + "\n");
return;
}
child.stdin.write(line + "\n");
}

process.stdin.on("data", (chunk: Buffer) => {
for (const line of stdinBuf.push(chunk)) {
processingChain = processingChain.then(() => processLine(line)).catch((err) => {
// Surface unexpected processing errors to stderr so Claude
// Desktop's log shows them; never propagate to keep the chain
// alive for subsequent lines.
process.stderr.write(
`agentlock mcp-proxy: processing error: ${(err as Error).message}\n`,
);
});
}
});
process.stdin.on("end", () => {
child.stdin.end();
// Wait for any in-flight processing before closing the child's stdin
// so queued writes don't land on an already-ended stream.
processingChain = processingChain.then(() => {
child.stdin.end();
});
});

// Child → us → Claude Desktop
Expand Down
13 changes: 8 additions & 5 deletions control-plane/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,14 @@ RUN install -d -m 0700 -o 65532 -g 65532 /out/agentlock-home
FROM gcr.io/distroless/cc-debian12 AS runtime
COPY --from=go-builder /out/control-plane /usr/local/bin/agentlockd
COPY --from=go-builder --chown=65532:65532 /out/agentlock-home /var/lib/agentlock
# No bundled policy file — daemon falls back to the built-in
# minimal monitor-mode policy in main.go (single rogue.destructive-bash
# gate). Operators install community rules via `agentlock rules sync`
# + `agentlock rules install <id>`. To pin a custom file, mount it
# and set AGENTLOCK_POLICY=/path/to/policy.yaml.
# No external policy file — daemon falls back to the baseline policy
# embedded into the binary at build time
# (control-plane/internal/policy/baseline.yaml): thirteen enforce-mode
# gates spanning destructive shell, supply-chain RCE, secret/credential
# reads, defence-evasion, destructive infra, and system/auth/shell-rc/
# cron persistence. Operators layer extras via `agentlock rules sync`
# + `agentlock rules install <id>`. To pin a custom file, mount it and
# set AGENTLOCK_POLICY=/path/to/policy.yaml.
ENV AGENTLOCK_LISTEN=0.0.0.0:7878
ENV AGENTLOCK_DASHBOARD_LISTEN=0.0.0.0:7879
ENV AGENTLOCK_HOME=/var/lib/agentlock
Expand Down
40 changes: 9 additions & 31 deletions control-plane/cmd/control-plane/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,17 @@ func splitHostPort(addr string) (host, port string, ok bool) {
}

// loadPolicy reads $AGENTLOCK_POLICY if set; otherwise returns a built-in
// minimal default (monitor mode, single destructive-bash gate) so the
// daemon always starts with *some* policy bound to session attestations.
// The first-boot expectation is that operators run
// `agentlock rules sync && agentlock rules install <id>` against the
// openagentlock/rules registry to populate real coverage. The bundled
// `policies/default.yaml` was deprecated and removed — registry-first
// is the new shape (see docs/guide/policies.md).
// loadPolicy returns the daemon's policy. With path="" we load the
// baseline embedded into the binary at build time
// (control-plane/internal/policy/baseline.yaml) — thirteen enforce-mode
// gates spanning destructive shell, supply-chain RCE, secret/credential
// reads, defence-evasion, destructive infra, and system/auth/shell-rc/
// cron persistence. Operators can still layer extras from
// openagentlock/rules via `agentlock rules install`, or pin a custom
// file with AGENTLOCK_POLICY=/path/to/policy.yaml.
func loadPolicy(path string) (*policy.Policy, error) {
if path == "" {
return policy.LoadBytes([]byte(defaultPolicyYAML))
return policy.LoadBytes(policy.DefaultBaseline())
}
f, err := os.Open(path)
if err != nil {
Expand All @@ -175,26 +176,3 @@ func loadPolicy(path string) (*policy.Policy, error) {
defer f.Close()
return policy.Load(f)
}

// defaultPolicyYAML is the minimal first-boot policy. It exists only so
// every session has a non-empty policy hash to attest against. Operators
// are expected to layer real coverage from openagentlock/rules on top.
const defaultPolicyYAML = `
version: 1
mode: monitor
defaults:
bash: allow
gates:
- id: rogue.destructive-bash
match:
tool: Bash
any_command_regex:
- 'rm\s+(-[rRfF]+\s+)+\S+'
- 'git\s+push\s+.*--force'
- 'kubectl\s+delete\s+'
- 'DROP\s+(TABLE|DATABASE|SCHEMA)'
- 'chmod\s+-R\b'
evaluate:
- kind: always
action: deny
`
12 changes: 7 additions & 5 deletions control-plane/internal/api/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,10 +520,12 @@ func harnessForPath(path string) string {
case strings.HasSuffix(path, "claude_desktop_config.json"),
strings.HasSuffix(path, "extensions-installations.json"):
return "claude-desktop"
case strings.HasSuffix(path, "manifest.json") &&
strings.Contains(path, "/Claude Extensions/"):
case filepath.Base(path) == "manifest.json" &&
filepath.Base(filepath.Dir(filepath.Dir(path))) == "Claude Extensions":
// Bundle manifest of a Desktop Extension. Path shape:
// <config-dir>/Claude Extensions/<ext-id>/manifest.json.
// filepath.Base avoids the cross-platform footgun of literal
// "/Claude Extensions/" not matching backslash paths on Windows.
return "claude-desktop"
case strings.HasSuffix(path, "settings.json"):
// Gemini also writes settings.json (in ~/.gemini); disambiguate
Expand Down Expand Up @@ -730,8 +732,8 @@ func installUninstallHandler(d Deps) http.HandlerFunc {
// - <Claude Extensions>/<id>/manifest.json (bundle manifests, the actual launch source)
if strings.HasSuffix(e.SettingsPath, "extensions-installations.json") {
newBytes, removed, stripErr = stripExtensionRegistry(existing)
} else if strings.HasSuffix(e.SettingsPath, "manifest.json") &&
strings.Contains(e.SettingsPath, "/Claude Extensions/") {
} else if filepath.Base(e.SettingsPath) == "manifest.json" &&
filepath.Base(filepath.Dir(filepath.Dir(e.SettingsPath))) == "Claude Extensions" {
newBytes, removed, stripErr = stripBundleManifest(existing)
} else {
newBytes, removed, stripErr = stripClaudeDesktopConfig(existing)
Expand Down Expand Up @@ -997,7 +999,7 @@ func installUninstallHarnessesHandler(d Deps) http.HandlerFunc {
absBundles = a
}
for path, body := range req.ExistingFiles {
if !strings.HasSuffix(path, "/manifest.json") {
if filepath.Base(path) != "manifest.json" {
continue
}
if filepath.Dir(filepath.Dir(path)) != absBundles {
Expand Down
6 changes: 5 additions & 1 deletion control-plane/internal/api/install_claude_desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,11 @@ func bundleManifestOps(bundlesDir, daemonURL, agentlockBinary string, settings m
}
var ops []fileOp
for path, body := range existingFiles {
if !strings.HasSuffix(path, "/manifest.json") {
// filepath.Base is platform-agnostic; strings.HasSuffix on a
// hard-coded "/manifest.json" misses Windows paths that use
// backslash separators when the CLI sends native-separator
// paths (CodeRabbit finding).
if filepath.Base(path) != "manifest.json" {
continue
}
// path layout: <abs>/<extID>/manifest.json
Expand Down
18 changes: 18 additions & 0 deletions control-plane/internal/policy/baseline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package policy

import _ "embed"

// baselineYAML is the first-boot policy bundled into the daemon binary.
// loaded by cmd/control-plane/main.go when AGENTLOCK_POLICY is unset.
// see baseline.yaml for the cross-harness rationale behind each gate.
//
//go:embed baseline.yaml
var baselineYAML []byte

// DefaultBaseline returns the embedded baseline policy bytes. Returns a
// fresh copy so callers can mutate without poisoning the embedded blob.
func DefaultBaseline() []byte {
out := make([]byte, len(baselineYAML))
copy(out, baselineYAML)
return out
}
Loading
Loading