Skip to content

feat(sdk): out of box controls - part 2 - #247

Open
namrataghadi-galileo wants to merge 13 commits into
feature/67101-out-of-box-controlsfrom
feature/67101-out-of-box-controls-phase-2
Open

feat(sdk): out of box controls - part 2#247
namrataghadi-galileo wants to merge 13 commits into
feature/67101-out-of-box-controlsfrom
feature/67101-out-of-box-controls-phase-2

Conversation

@namrataghadi-galileo

Copy link
Copy Markdown
Contributor

Summary

  • Added the Phase 2 static out-of-box control catalog so new installs seed useful non-Luna controls at startup.
  • Included regex PII/shell controls, JSON approval controls, and list-based RBAC/tool allowlist controls using the Phase 1 idempotent bootstrap path.

Scope

  • User-facing/API changes: No API contract changes. Seeded controls will appear in the existing Controls tab/list after startup.
  • Internal changes: Populated OUT_OF_BOX_CONTROL_TEMPLATES and expanded bootstrap/evaluator behavior tests.
  • Out of scope: Phase 3 Luna scorer metadata lookup and org-scoped Luna control generation.

Risk and Rollout

  • Risk level: low
  • Rollback plan: Revert the Phase 2 catalog additions in server/src/agent_control_server/bootstrap/out_of_box_controls.py, or remove specific templates from OUT_OF_BOX_CONTROL_TEMPLATES.

Testing

  • Added or updated automated tests
  • Ran make check (not run because uv dependency resolution hit private index/auth issues previously)
  • Manually verified behavior via targeted server tests, full server test suite, server lint, and server mypy using the existing .venv

Checklist

  • Linked issue/spec (Phase 2 from OOTB controls technical spec)
  • Updated docs/examples for user-facing changes: N/A, no API/docs contract changes
  • Included any required follow-up tasks: Phase 3 Luna controls remain follow-up work

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@namrataghadi-galileo
namrataghadi-galileo changed the base branch from main to feature/67101-out-of-box-controls June 30, 2026 17:12
r"\b(?:rm\s+-rf\s+(?:/|~|\$HOME)|sudo\s+rm\s+-rf|"
r"mkfs(?:\.[a-z0-9]+)?|dd\s+if=[^\s]+\s+of=/dev/[^\s]+|"
r"chmod\s+-R\s+777\s+/|chown\s+-R\s+[^|;&]*\s+/|"
r"shutdown\s+(?:-h\s+)?now|reboot)\b"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared trailing \b cannot match after / or ~, so rm -rf /, rm -rf ~, chmod -R 777 /, and chown -R root / pass this control. Please use explicit end/separator handling per alternative and add these cases to the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Cover equivalent destructive command forms

The revised pattern still misses rm -rf "$HOME", rm -rf ~/, and rm -fr /, so simple quoting, a trailing slash, or equivalent flag ordering bypasses this deny control. Please normalize command arguments or cover these equivalent forms in both the pattern and regression tests.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Normalize recursive-rm arguments

The new cases pass, but rm -rf -- "$HOME" and rm --recursive --force "$HOME" still bypass this deny rule, while the targetless sudo arm blocks safe sudo rm -rf /tmp/cache. Tokenize and normalize flags plus the actual target, including --, quoting, sudo, and long options, so the control distinguishes root or home deletion from scoped deletion.

"anyOf": [
{
"required": ["approved"],
"properties": {"approved": {"const": True}},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we avoid treating a caller-controlled approved flag as authorization evidence? A prompt-injected agent can set or replay it, while tools without this field cannot complete the flow. This should use a trusted approval artifact or host workflow bound to the action; the outbound template below has the same issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by removing caller-controlled approval flags as bypasses from both templates. Qualifying high-value and outbound actions now always steer to a trusted host workflow, which must bind its approval artifact to the exact action. Tests verify that both top-level and nested approval flags cannot bypass the controls.

name="oob-sensitive-tool-requires-approved-role",
data=_leaf_control_payload(
description="Deny sensitive tool use when runtime context has an unapproved role.",
selector_path="context.user.role",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fails open when context.user.role is missing: the selector returns None, and ListEvaluator returns matched=False before applying match_on="no_match". Please fail closed for missing/unknown roles and source the role from trusted principal context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. AC does not yet provide a trusted principal-role contract, so this control cannot securely distinguish authorized roles at the control layer. Making the selector fail closed would address missing values but would still trust caller-controlled context. We’ll remove this OOTB control for now and reintroduce it when RBAC/trusted principal context is supported.

selector_path="name",
evaluator_name="list",
evaluator_config={
"values": ["search", "web_search", "retrieve", "calculator"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact bare names do not match integrations that qualify tool names. Google ADK emits names such as <agent>.web_search, so this denies an explicitly approved tool. Please normalize tool identity across integrations or match a canonical raw name, with an integration-level test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by adding a canonical step identity to the shared Step contract. Integrations can retain qualified names for registration while controls evaluate the integration-independent tool name. Google ADK now sends web_search as canonical identity for names such as writer.web_search; Strands is wired similarly. Added ADK integration coverage and fallback tests for unqualified names.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep older SDKs compatible

This fixes current ADK calls, but pre-PR clients still send writer.web_search without canonical_name; the fallback returns that qualified name, so this exact allowlist matches no_match and denies an approved tool. Please normalize the legacy qualified form server-side or gate this control on an enforced minimum SDK version, and add a mixed-version test.

try:
if namespace_key == default_out_of_box_namespace_key():
return
if await _namespace_has_active_controls(db, namespace_key=namespace_key):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking for any active control makes partial seeding permanent: templates commit separately, so one transient failure leaves rows that cause every later request to return here. It also skips tenants with one custom control. Please check expected seed identities or always run the idempotent per-template pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by removing the namespace-wide active-control check and always running the idempotent per-template seed pass. This allows partially seeded namespaces to recover and ensures a tenant’s custom controls do not suppress OOTB seeding. Also added regression coverage for both scenarios.

return
if await _namespace_has_active_controls(db, namespace_key=namespace_key):
return
await seed_out_of_box_controls(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path is reached from a GET authorized only with controls.read, but it commits Control and ControlVersion rows. Could seeding move to trusted tenant provisioning, or require controls.create resolved to the same namespace, so read-only callers cannot trigger writes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This write-on-GET behavior is intentional for OOTB controls. The Galileo All controls page only calls GET /api/v1/controls?cloned=false; there is currently no tenant-provisioning call, and the organization namespace is resolved from the existing controls.read authorization. We need the fixed, server-owned OOTB controls to appear on the first page load, regardless of agent or logstream attachment. The reconciliation is idempotent, uses immutable seed identities, and respects deletion tombstones; callers cannot provide or modify the seeded payloads. Standalone/open-source deployments are seeded at startup, while this path covers dynamically discovered organization namespaces without an additional authorization request.

@namrataghadi-galileo namrataghadi-galileo changed the title feat(sdk): out of box controls phase 2 feat(sdk): out of box controls - part 2 Jul 30, 2026
name="oob-only-approved-tools-may-run",
data=_leaf_control_payload(
description="Deny tool calls whose step name is not in the approved tool list.",
selector_path="canonical_name",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep seeded controls rollback-compatible

This persists a selector that PR 246 rejects, while reconciliation never removes existing seed identities, so removing the template leaves previously seeded controls in place and a full code rollback makes their stored definitions invalid. Please add an explicit cleanup/data migration or establish a rollback floor that retains canonical_name support.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Make the rollback path run before old code serves

The added downgrade() does not run in a code rollback from PR 247 to PR 246 because both revisions remain f3a1c8d7e2b4, so old pods still encounter persisted canonical_name rows. Even an explicit downgrade filters by seed source, leaving clone-and-bind copies without seed_source_id incompatible. Stage reader support before seeding, or enforce a rollback floor or new migration boundary that cleans every persisted copy.

attachment_target_type=attachment_target_type,
attachment_target_id=attachment_target_id,
):
await _seed_out_of_box_controls_for_namespace(namespace_key=namespace_key)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bound lazy namespace reconciliation

Every qualifying first-page GET waits for a fresh reconciliation; already-seeded namespaces issue one serial existence query per template, while cold or concurrent requests add per-template queries and commits. Bulk-fetch existing seed identities and bound or deduplicate the whole reconciliation so one controls read cannot absorb multiple statement timeouts.

selector_path="input.query",
evaluator_name="sql",
evaluator_config={
"allowed_operations": ["SELECT"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Treat SELECT INTO as a write

With this configuration, PostgreSQL SELECT * INTO backup FROM users returns no match even though it creates a table, because the SQL evaluator sees SELECT but does not classify the INTO node as DDL. Reject SELECT ... INTO, or defer this preset until the evaluator can, and add the exact regression before calling it read-only.

evaluator_name="sql",
evaluator_config={
"require_limit": True,
"max_limit": 1000,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Fail closed on indeterminate result bounds

LIMIT $1, LIMIT (1000 + 1), and LIMIT 1000 OFFSET $1 all pass this preset because the evaluator skips nonliteral limits and treats an unknown offset as zero. Reject indeterminate LIMIT or OFFSET expressions whenever a maximum is configured, or the 1000-row window remains bypassable.

name: str = Field(
..., min_length=1, description="Step name (tool name or model/chain id)"
)
canonical_name: str | None = Field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the existing wildcard payload shape

select_data(step, "*") dumps the whole model, so current tool integrations now add canonical_name to every wildcard evaluation. Existing JSON controls with additionalProperties: false change from non-match to match and can deny previously valid calls; exclude this field from the legacy * view or version that selector shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants