{"slug": "dev-log-2026-06-24-agent-guardrails-and-runtime-ldap-config", "title": "Dev Log: 2026-06-24 — agent guardrails and runtime LDAP config", "summary": "A developer built MCP tools for an AI agent to drive an events platform, enforcing organization-scoped access and reusing existing permission strings to prevent privilege escalation. They also made LDAP connection settings fully editable from an admin UI, implementing a cache-backed settings layer that overrides deploy-time config and restoring integer keys from JSON to ensure compatibility with ldap_set_option.", "body_md": "Two threads today, and they rhyme more than I expected: both are about *who gets to do what, and how you keep that boundary honest.* One was exposing an app to an AI agent safely; the other was moving config out of deploy-time files and into a settings UI without losing the guardrails. Different problems, same instinct — make the boundary explicit and put it in one place.\n\nI spent most of the day building out a set of MCP tools so an AI agent can drive an events platform: list events, check readiness, publish, run the lifecycle. The interesting part isn't the tools — it's the fencing around them.\n\nMCP tools authenticate with a token, which means there's **no ambient \"current tenant\" context** the way a session-backed web request has. If you lean on a global tenant scope, it reads nothing and your \"fenced\" query quietly returns everyone's rows. So the rule became: under token auth, filter by organization *explicitly*, every time, in one shared trait — never a global scope you hope is active.\n\nOn top of scope, every tool declares a single `ability()`\n\n— and it reuses the **same permission strings the web app already enforces**, so the agent can never be more privileged than the human behind it. Read tools get an `#[IsReadOnly]`\n\nannotation so destructive calls are visible at a glance. Lookups are by UUID, never the enumerable auto-increment ID.\n\nI wrote this thread up properly as its own post (RBAC + org-scoped MCP tools) because the pattern generalizes well beyond this app. Short version of the lesson: **treat every MCP tool as an untrusted endpoint, and put the boundary logic somewhere you can test it in one shot.**\n\n```\nit('never resolves a record from another organization', function () {\n    $mine = Event::factory()->for($orgA)->create();\n    $theirs = Event::factory()->for($orgB)->create();\n    $user = User::factory()->for($orgA)->create();\n\n    expect(resolveOrgEvent($user, $mine->uuid))->not->toBeNull()\n        ->and(resolveOrgEvent($user, $theirs->uuid))->toBeNull();\n});\n```\n\nThe other change was on an identity/AD portal: making the LDAP connection settings fully editable from an admin UI, instead of being frozen in `config/ldap.php`\n\nat deploy time. Previously only the obvious fields were exposed — host, port, base DN, the bind credentials, SSL/TLS, search attribute. Now the awkward ones are too: connection timeout, SASL toggle and options, and the raw `ldap_set_option`\n\nmap.\n\nThat last one is a nice little gotcha worth sharing. The options map uses **integer keys that are LDAP option constants** (e.g. `17`\n\nis `LDAP_OPT_PROTOCOL_VERSION`\n\n). But the moment you store that map as JSON and decode it back, those keys come back as *strings* — and the underlying `ldap_set_option`\n\ncalls won't match on string keys. So the settings accessor has to restore the integer keys after decoding before the values are usable:\n\n```\n// JSON round-trips integer keys to strings; restore them so the\n// values pass straight to ldap_set_option / LdapRecord.\n$options = collect(json_decode($raw, true))\n    ->mapWithKeys(fn ($value, $key) => [\n        is_numeric($key) ? (int) $key : $key => $value,\n    ])\n    ->all();\n```\n\nThe pattern underneath is a cache-backed settings layer that **overrides deploy-time config() at boot**: a service provider reads the stored values and hydrates\n\n`config()`\n\n(and the LDAP container) before anything uses them. Two guardrails make it safe to hand to an admin:And a small design win: the same `connectionConfig()`\n\nassembler backs both *Save* and *Test connection*, so the connection you test is byte-for-byte the connection you save. No \"works in test, fails live\" gap because two code paths built the config slightly differently.\n\nBoth changes are really the same move: take something that was implicit or frozen — ambient tenancy, deploy-time config — make it explicit and runtime-controllable, then wrap it in guardrails you can point at. Explicit org filter in one trait. Encrypted secret that never hits the cache. Validation at the boundary. One assembler for save-and-test. The feature is the easy part; the guardrail is the engineering.\n\nWhat's next: more lifecycle tools on the agent side, and probably a \"test connection\" surfaced directly in the settings UI for every LDAP field, not just the core ones.", "url": "https://wpnews.pro/news/dev-log-2026-06-24-agent-guardrails-and-runtime-ldap-config", "canonical_source": "https://dev.to/nasrulhazim/dev-log-2026-06-24-agent-guardrails-and-runtime-ldap-config-2hi5", "published_at": "2026-06-25 00:45:49+00:00", "updated_at": "2026-06-25 01:13:13.539900+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-safety", "ai-infrastructure"], "entities": ["MCP", "LDAP", "RBAC", "LdapRecord"], "alternates": {"html": "https://wpnews.pro/news/dev-log-2026-06-24-agent-guardrails-and-runtime-ldap-config", "markdown": "https://wpnews.pro/news/dev-log-2026-06-24-agent-guardrails-and-runtime-ldap-config.md", "text": "https://wpnews.pro/news/dev-log-2026-06-24-agent-guardrails-and-runtime-ldap-config.txt", "jsonld": "https://wpnews.pro/news/dev-log-2026-06-24-agent-guardrails-and-runtime-ldap-config.jsonld"}}