Skip to content

Commit fc6733d

Browse files
guima-whyclaude
andcommitted
feat: llm_source lock, bash permission improvements & ACP rule-level permissions
1. Lock /model and /auth LLM flow when llm_source is not "local" 2. Parser: per-command is_complex field instead of global too_complex abort 3. Permission engine: skip dangerous builtin suggestions (eval/exec/source) 4. UI: add rule-level deny option in permission prompt 5. Rename distribution to iac_code (normalized, pip install iac-code still works) 6. Add new models: qwen3.6-flash, deepseek-v4-pro/flash, glm-5.1, kimi-k2.6 7. i18n: translations for all new strings across 6 languages 8. ACP: implement rule-level permission options aligned with local REPL - Dynamic option_id encoding (allow_rule:<rules>, deny_rule:<rules>) - Apply session-scoped rules to permission_context - Fall back to tool-level options when no suggestions present - 14 unit tests + 4 e2e tests, 351 total ACP tests pass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b5f875c commit fc6733d

24 files changed

Lines changed: 2047 additions & 733 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[project]
2-
name = "iac-code"
2+
name = "iac_code"
33
dynamic = ["version"]
44
description = "Your AI-powered Infrastructure as Code assistant"
55
readme = "README.md"

src/iac_code/acp/session.py

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ def _history_message_to_updates(msg: Message) -> list[Any]:
152152
_OPTION_ALLOW_ALWAYS = "allow_always"
153153
_OPTION_REJECT_ONCE = "reject_once"
154154
_OPTION_REJECT_ALWAYS = "reject_always"
155+
_PREFIX_ALLOW_RULE = "allow_rule:"
156+
_PREFIX_DENY_RULE = "deny_rule:"
155157

156158

157159
class ACPSession:
@@ -373,6 +375,30 @@ async def cancel(self) -> None:
373375
logger.info("Session %s cancel requested", self.id)
374376
self._current_task.cancel()
375377

378+
def _get_permission_context(self):
379+
"""Read the agent_loop's mutable permission context."""
380+
return getattr(self.agent_loop, "_permission_context", None)
381+
382+
def _set_permission_context(self, perm_ctx) -> None:
383+
"""Write back the updated permission context to agent_loop."""
384+
if hasattr(self.agent_loop, "_permission_context"):
385+
self.agent_loop._permission_context = perm_ctx
386+
387+
def _apply_rule(self, tool_name: str, rules_str: str, behavior: str) -> None:
388+
"""Apply rule-level permission to the session's permission_context."""
389+
from iac_code.services.permissions.storage import apply_session_rule
390+
from iac_code.types.permissions import PermissionRuleValue
391+
392+
perm_ctx = self._get_permission_context()
393+
if perm_ctx is None:
394+
return
395+
for rule_content in rules_str.split(","):
396+
rule_content = rule_content.strip()
397+
if rule_content:
398+
rule_value = PermissionRuleValue(tool_name=tool_name, rule_content=rule_content)
399+
perm_ctx = apply_session_rule(perm_ctx, behavior, rule_value)
400+
self._set_permission_context(perm_ctx)
401+
376402
async def _request_permission(self, event: PermissionRequestEvent) -> bool:
377403
tool_name = event.tool_name
378404

@@ -385,59 +411,113 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool:
385411
logger.debug("Permission auto-denied for tool %s (cached)", tool_name)
386412
return False
387413

388-
response = await self._conn.request_permission(
389-
[
414+
# Extract suggestions from permission_result for rule-level options.
415+
suggestions = []
416+
if (
417+
event.permission_result is not None
418+
and hasattr(event.permission_result, "suggestions")
419+
and event.permission_result.suggestions
420+
):
421+
suggestions = event.permission_result.suggestions
422+
423+
# Build dynamic option list aligned with local REPL behavior.
424+
options: list[acp.schema.PermissionOption] = [
425+
acp.schema.PermissionOption(
426+
option_id=_OPTION_ALLOW_ONCE,
427+
name="Allow once",
428+
kind="allow_once",
429+
),
430+
]
431+
432+
if suggestions:
433+
rules_display = ",".join(s.rule_content for s in suggestions)
434+
options.append(
390435
acp.schema.PermissionOption(
391-
option_id=_OPTION_ALLOW_ONCE,
392-
name="Allow once",
393-
kind="allow_once",
394-
),
436+
option_id=_PREFIX_ALLOW_RULE + rules_display,
437+
name='Always allow "{}" (this session)'.format(rules_display),
438+
kind="allow_always",
439+
)
440+
)
441+
else:
442+
options.append(
395443
acp.schema.PermissionOption(
396444
option_id=_OPTION_ALLOW_ALWAYS,
397-
name="Always allow",
445+
name="Always allow this tool",
398446
kind="allow_always",
399-
),
400-
acp.schema.PermissionOption(
401-
option_id=_OPTION_REJECT_ONCE,
402-
name="Reject once",
403-
kind="reject_once",
404-
),
447+
)
448+
)
449+
450+
options.append(
451+
acp.schema.PermissionOption(
452+
option_id=_OPTION_REJECT_ONCE,
453+
name="Reject once",
454+
kind="reject_once",
455+
)
456+
)
457+
458+
if suggestions:
459+
rules_display = ",".join(s.rule_content for s in suggestions)
460+
options.append(
405461
acp.schema.PermissionOption(
406-
option_id=_OPTION_REJECT_ALWAYS,
407-
name="Always reject",
462+
option_id=_PREFIX_DENY_RULE + rules_display,
463+
name='Always deny "{}" (this session)'.format(rules_display),
408464
kind="reject_always",
409-
),
410-
],
465+
)
466+
)
467+
468+
options.append(
469+
acp.schema.PermissionOption(
470+
option_id=_OPTION_REJECT_ALWAYS,
471+
name="Always reject this tool",
472+
kind="reject_always",
473+
),
474+
)
475+
476+
# Build content with command details and suggested rule.
477+
content_text = "Approve tool call: {}\nInput: {}".format(tool_name, event.tool_input)
478+
if suggestions:
479+
content_text += "\nSuggested rule: {}".format(",".join(s.rule_content for s in suggestions))
480+
481+
response = await self._conn.request_permission(
482+
options,
411483
self.id,
412484
acp.schema.ToolCallUpdate(
413-
tool_call_id=f"permission/{event.tool_use_id}",
485+
tool_call_id="permission/{}".format(event.tool_use_id),
414486
title=event.tool_name,
415487
content=[
416488
acp.schema.ContentToolCallContent(
417489
type="content",
418490
content=acp.schema.TextContentBlock(
419491
type="text",
420-
text=f"Approve tool call {event.tool_name} with input: {event.tool_input}",
492+
text=content_text,
421493
),
422494
)
423495
],
424496
),
425497
)
426498

427-
# Interpret the outcome and update the permission cache
499+
# Interpret the outcome and update permission state.
428500
if isinstance(response.outcome, acp.schema.AllowedOutcome):
429501
option_id = response.outcome.option_id
430502
if option_id == _OPTION_ALLOW_ALWAYS:
431503
self._cache_permission(tool_name, "always_allow")
504+
elif option_id and option_id.startswith(_PREFIX_ALLOW_RULE):
505+
rules_str = option_id[len(_PREFIX_ALLOW_RULE) :]
506+
self._apply_rule(tool_name, rules_str, "allow")
432507
return True
433508

434-
# DeniedOutcome — the ACP SDK DeniedOutcome has no option_id field,
435-
# so clients that want to signal "reject_always" should set
436-
# _meta={"option_id": "reject_always"} on the *response* envelope.
509+
# DeniedOutcome — parse option_id from meta or direct field.
437510
if isinstance(response.outcome, acp.schema.DeniedOutcome):
438-
resp_meta = getattr(response, "field_meta", None) or {}
439-
if resp_meta.get("option_id") == _OPTION_REJECT_ALWAYS:
511+
option_id = getattr(response.outcome, "option_id", None)
512+
if option_id is None:
513+
resp_meta = getattr(response, "field_meta", None) or {}
514+
option_id = resp_meta.get("option_id")
515+
516+
if option_id == _OPTION_REJECT_ALWAYS:
440517
self._cache_permission(tool_name, "always_deny")
518+
elif option_id and option_id.startswith(_PREFIX_DENY_RULE):
519+
rules_str = option_id[len(_PREFIX_DENY_RULE) :]
520+
self._apply_rule(tool_name, rules_str, "deny")
441521

442522
return False
443523

src/iac_code/commands/auth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_save_yaml,
1919
get_active_provider_key,
2020
get_credentials_path,
21+
get_llm_source,
2122
get_provider_config,
2223
get_settings_path,
2324
)
@@ -570,6 +571,24 @@ async def auth_command(context: "CommandContext | None" = None, **kwargs) -> str
570571

571572
def _auth_flow(console, store) -> str | None:
572573
"""Auth flow running inside alternate screen."""
574+
llm_source = get_llm_source()
575+
if llm_source != "local":
576+
lock_notice = _("LLM provider is locked by '{source}'. To change, modify llm_source in settings.yml.").format(
577+
source=llm_source
578+
)
579+
options = [_cloud_provider_display(p["name"]) for p in CLOUD_PROVIDERS]
580+
idx = _select("{}\n\n{}".format(lock_notice, _("Select Cloud Provider")), options)
581+
if idx is None:
582+
return _("Auth cancelled")
583+
provider = CLOUD_PROVIDERS[idx]
584+
if provider["name"] == "aliyun":
585+
result = _aliyun_auth_flow()
586+
else:
587+
result = _BACK
588+
if isinstance(result, _BackSentinel):
589+
return _("Auth cancelled")
590+
return result
591+
573592
while True:
574593
# Step 0: Select category
575594
categories = [

src/iac_code/commands/model.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
save_active_provider_config,
1515
select_model_interactive,
1616
)
17-
from iac_code.config import _load_yaml, get_active_provider_key, get_settings_path
17+
from iac_code.config import _load_yaml, get_active_provider_key, get_llm_source, get_settings_path
1818
from iac_code.i18n import _
1919
from iac_code.services.telemetry import log_event
2020
from iac_code.services.telemetry.names import Events
@@ -44,6 +44,12 @@ def _get_active_provider_models() -> list[str]:
4444

4545
async def model_command(context: "CommandContext | None" = None, args: list[str] | None = None, **kwargs) -> str | None:
4646
"""Switch or display current model."""
47+
llm_source = get_llm_source()
48+
if llm_source != "local":
49+
return _("Model selection is locked by '{source}'. To change, modify llm_source in settings.yml.").format(
50+
source=llm_source
51+
)
52+
4753
store = context.store if context else kwargs.get("store")
4854
args = args or []
4955

0 commit comments

Comments
 (0)