diff --git a/src/iac_code/a2a/events.py b/src/iac_code/a2a/events.py index ffdd24f6..cdcd8426 100644 --- a/src/iac_code/a2a/events.py +++ b/src/iac_code/a2a/events.py @@ -25,11 +25,19 @@ from iac_code.a2a.exposure import A2AExposureType, normalize_a2a_exposure_types from iac_code.a2a.metadata_redaction import A2AMetadataEchoRedactor from iac_code.i18n import _ +from iac_code.services.permissions.audit import ( + build_input_summary, + build_redacted_tool_input, + emit_auto_permission_audit, + emit_permission_boundary_audit, + is_aliyun_api_non_read_only_permission_event, +) from iac_code.types.stream_events import ( ErrorEvent, MCPProgressEvent, MessageEndEvent, PermissionRequestEvent, + SubPipelineStreamEvent, TextDeltaEvent, ThinkingDeltaEvent, ToolInputDeltaEvent, @@ -77,6 +85,22 @@ def _sanitize_trace_input(value: Any) -> Any: return _METADATA_REDACTOR.redact(_truncate(value)) +def _public_tool_input_metadata(tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]: + return {"inputSummary": build_input_summary(tool_name, tool_input)} + + +def _public_permission_input_metadata(tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]: + if tool_name == "aliyun_api": + return {"inputSummary": build_input_summary(tool_name, tool_input)} + return {"toolInput": build_redacted_tool_input(tool_input)} + + +def _permission_request_event(event: Any) -> PermissionRequestEvent | None: + while isinstance(event, SubPipelineStreamEvent): + event = event.inner + return event if isinstance(event, PermissionRequestEvent) else None + + def make_text_part(text: str) -> Part: return Part(text=text) @@ -285,7 +309,7 @@ async def publish_stream_event( "tool": { "status": "input_delta", "toolUseId": event.tool_use_id, - "partialJson": _truncate(event.partial_json), + "partialJsonLength": len(event.partial_json), } } }, @@ -307,7 +331,7 @@ async def publish_stream_event( "status": "input_complete", "toolUseId": event.tool_use_id, "name": event.name, - "input": _sanitize_trace_input(event.input), + **_public_tool_input_metadata(event.name, event.input), } } }, @@ -369,13 +393,23 @@ async def publish_stream_event( ) return None - if isinstance(event, PermissionRequestEvent): + permission_event = _permission_request_event(event) + if permission_event is not None: approved = auto_approve_permissions if permission_resolver is not None: - decision = permission_resolver(event) + decision = permission_resolver(permission_event) approved = bool(await decision) if inspect.isawaitable(decision) else bool(decision) - if event.response_future is not None and not event.response_future.done(): - event.response_future.set_result(approved) + elif is_aliyun_api_non_read_only_permission_event(permission_event): + approved = False + if permission_event.response_future is not None and not permission_event.response_future.done(): + audit_ok = True + if permission_resolver is not None: + audit_ok = _emit_resolver_permission_audit(permission_event, approved) + else: + audit_ok = _emit_auto_permission_audit(permission_event, approved) + if approved and not audit_ok: + approved = False + permission_event.response_future.set_result(approved) if A2AExposureType.TOOL_TRACE not in enabled_exposure_types: return None await _enqueue_status( @@ -387,9 +421,9 @@ async def publish_stream_event( "iac_code": { "permission": { "autoApproved": approved, - "toolName": event.tool_name, - "toolUseId": event.tool_use_id, - "toolInput": _sanitize_trace_input(event.tool_input), + "toolName": permission_event.tool_name, + "toolUseId": permission_event.tool_use_id, + **_public_permission_input_metadata(permission_event.tool_name, permission_event.tool_input), } } }, @@ -440,3 +474,50 @@ async def publish_stream_event( logger.debug("Skipping unmapped A2A stream event: %s", type(event).__name__) return None + + +def _emit_auto_permission_audit( + request: PermissionRequestEvent, + approved: bool, + *, + persistence_failure: bool = False, +) -> bool: + if persistence_failure: + return emit_permission_boundary_audit( + request, + decision="deny", + scope="auto_deny", + source="a2a_auto_persistence_failure", + reason_type="persistence_failure", + reason_detail="permission metadata persistence failed", + ) + source = "a2a_auto_approve" if approved else "a2a_auto_deny" + return emit_auto_permission_audit( + request, + decision="allow" if approved else "deny", + scope="auto_approve" if approved else "auto_deny", + source=source, + ) + + +def _emit_resolver_permission_audit( + request: PermissionRequestEvent, + approved: bool, + *, + persistence_failure: bool = False, +) -> bool: + source = "a2a_resolver" + reason_type = "a2a_resolver" + reason_detail = "allow" if approved else "deny" + if persistence_failure: + source = "a2a_resolver_persistence_failure" + reason_type = "persistence_failure" + reason_detail = "permission metadata persistence failed" + return emit_permission_boundary_audit( + request, + decision="allow" if approved else "deny", + scope="a2a_resolver", + source=source, + reason_type=reason_type, + reason_detail=reason_detail, + ) diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index 85f3fb99..defc72a9 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -11,6 +11,7 @@ from iac_code.a2a.artifacts import UnsafeArtifactNameError, artifact_filename_from_path, sanitize_public_artifact_text from iac_code.a2a.events import _tool_result_metadata, _truncate from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.services.permissions.audit import build_input_summary, build_redacted_tool_input, fingerprint_text from iac_code.types.stream_events import ( AskUserQuestionEvent, CandidateDetailEvent, @@ -75,10 +76,27 @@ _NESTED_DATA_KEY_ALIASES = { "error_id": "errorId", } -_PERMISSION_METADATA_MAX_CHARS = 4000 -_PERMISSION_METADATA_MAX_DEPTH = 32 _PERMISSION_SUMMARY_MAX_FIELDS = 20 _PERMISSION_SUMMARY_MAX_CHARS = 256 +_SAFE_PERMISSION_SUMMARY_FIELDS = { + "action", + "body", + "cmd", + "command", + "content", + "cwd", + "input", + "method", + "params", + "path", + "pathname", + "product", + "query", + "region", + "region_id", + "timeout", + "url", +} _SECRET_KEY_FRAGMENTS = ( "api_key", "apikey", @@ -96,6 +114,7 @@ "credential", "authorization", "private_key", + "signature", ) _STACK_TOOL_ACTIONS = {"CreateStack", "UpdateStack", "ContinueCreateStack", "DeleteStack"} _STACK_CLEAR_ACTIONS = {"DeleteStack"} @@ -501,8 +520,9 @@ def _completion_artifact_events(self, completed: dict[str, Any]) -> list[dict[st return events def _translate_sub_pipeline_stream_event(self, event: SubPipelineStreamEvent) -> list[dict[str, Any]]: - state = self._candidate_from_stream_event(event) - inner = event.inner + stream_event = _innermost_sub_pipeline_stream_event(event) + state = self._candidate_from_stream_event(stream_event) + inner = stream_event.inner if isinstance(inner, TextDeltaEvent): event_type = "text_delta" data = {"text": inner.text} @@ -1181,19 +1201,28 @@ def _permission_request_metadata(event: PermissionRequestEvent) -> dict[str, Any return safe_permission_metadata(event) +def _innermost_sub_pipeline_stream_event(event: SubPipelineStreamEvent) -> SubPipelineStreamEvent: + while isinstance(event.inner, SubPipelineStreamEvent): + event = event.inner + return event + + def safe_permission_metadata( event: PermissionRequestEvent, *, include_tool_input: bool = True, ) -> dict[str, Any]: - metadata = { + metadata: dict[str, Any] = { "permissionId": f"perm-{event.tool_use_id}", "toolName": event.tool_name, "toolUseId": event.tool_use_id, "safeSummary": _permission_safe_summary(event), } if include_tool_input: - metadata["toolInput"] = _redact_permission_value(event.tool_input) + if event.tool_name == "aliyun_api": + metadata["inputSummary"] = build_input_summary(event.tool_name, event.tool_input) + else: + metadata["toolInput"] = build_redacted_tool_input(event.tool_input) return metadata @@ -1219,21 +1248,6 @@ def _permission_safe_summary(event: PermissionRequestEvent) -> str: return summary[: _PERMISSION_SUMMARY_MAX_CHARS - 3] + "..." -def _redact_permission_value(value: Any, *, _depth: int = 0) -> Any: - if _depth >= _PERMISSION_METADATA_MAX_DEPTH: - return "[truncated-depth]" - if isinstance(value, str): - return sanitize_public_artifact_text(value)[:_PERMISSION_METADATA_MAX_CHARS] - if isinstance(value, dict): - return { - str(key): "[redacted]" if _is_secret_key(key) else _redact_permission_value(item, _depth=_depth + 1) - for key, item in value.items() - } - if isinstance(value, list): - return [_redact_permission_value(item, _depth=_depth + 1) for item in value] - return _truncate(value, _depth=_depth) - - def _is_secret_key(key: Any) -> bool: normalized = str(key).lower().replace("-", "_") compact = normalized.replace("_", "") @@ -1241,7 +1255,10 @@ def _is_secret_key(key: Any) -> bool: def _safe_permission_field_name(key: Any) -> str: - return "[redacted]" if _is_secret_key(key) else str(key) + if _is_secret_key(key): + return "[redacted]" + text = str(key) + return text if text in _SAFE_PERMISSION_SUMMARY_FIELDS else fingerprint_text(text) def _tool_result_data(event: ToolResultEvent) -> dict[str, Any]: diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index 8ad950fd..1dff82e3 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -10,7 +10,13 @@ from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent from google.protobuf.json_format import ParseDict -from iac_code.a2a.events import _artifact_update_event, _extract_artifact_metadata, make_text_part +from iac_code.a2a.events import ( + _artifact_update_event, + _emit_auto_permission_audit, + _emit_resolver_permission_audit, + _extract_artifact_metadata, + make_text_part, +) from iac_code.a2a.exposure import A2AExposureType, normalize_a2a_exposure_types from iac_code.a2a.pipeline_events import PipelineEventTranslator, safe_permission_metadata from iac_code.a2a.pipeline_journal import A2APipelineJournal, to_json_safe @@ -21,6 +27,7 @@ PIPELINE_EVENT_CLEANUP_PROGRESS, PIPELINE_EVENT_CLEANUP_STARTED, ) +from iac_code.services.permissions.audit import is_aliyun_api_non_read_only_permission_event from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent, ToolResultEvent PipelinePermissionResolver = Callable[[PermissionRequestEvent], bool | Awaitable[bool]] @@ -129,8 +136,20 @@ async def publish( permission_resolver=permission_resolver, auto_approve_permissions=auto_approve_permissions, ) + permission_audit_emitted = False + if approved and _can_resolve_permission_future(permission_request): + audit_ok = ( + _emit_resolver_permission_audit(permission_request, approved) + if permission_resolver is not None + else _emit_auto_permission_audit(permission_request, approved) + ) + permission_audit_emitted = True + if not audit_ok: + approved = False + _set_permission_approval(envelope, approved) else: approved = None + permission_audit_emitted = False persisted = await self._persist_and_enqueue( envelope, @@ -138,7 +157,30 @@ async def publish( require_durable_metadata=permission_request is not None, ) if permission_request is not None: - _resolve_permission_future(permission_request, bool(approved) if persisted else False) + final_approved = bool(approved) if persisted else False + if _can_resolve_permission_future(permission_request): + if permission_audit_emitted and bool(approved) and not persisted: + if permission_resolver is not None: + _emit_resolver_permission_audit( + permission_request, + False, + persistence_failure=True, + ) + else: + _emit_auto_permission_audit( + permission_request, + False, + persistence_failure=True, + ) + elif not permission_audit_emitted: + audit_ok = ( + _emit_resolver_permission_audit(permission_request, final_approved) + if permission_resolver is not None + else _emit_auto_permission_audit(permission_request, final_approved) + ) + if final_approved and not audit_ok: + final_approved = False + _resolve_permission_future(permission_request, final_approved) if envelope.get("eventType") == "text_delta": text_parts.append(_text_from_envelope(envelope)) @@ -439,17 +481,14 @@ async def _apply_permission_metadata( if permission_resolver is not None: result = permission_resolver(request) approved = bool(await result) if inspect.isawaitable(result) else bool(result) + elif is_aliyun_api_non_read_only_permission_event(request): + approved = False include_tool_input = A2AExposureType.TOOL_TRACE in self.exposure_types permission = envelope.setdefault("permission", {}) permission.clear() permission.update(safe_permission_metadata(request, include_tool_input=include_tool_input)) - permission.update( - { - "approved": approved, - "decision": "allow_once" if approved else "deny", - } - ) + permission.update(_permission_approval_metadata(approved)) return approved async def _enqueue_status(self, envelope: dict[str, Any]) -> None: @@ -474,18 +513,40 @@ def _delivery_context_id(self, envelope: dict[str, Any]) -> str: def _permission_request_from(event: Any) -> PermissionRequestEvent | None: - inner = event.inner if isinstance(event, SubPipelineStreamEvent) else event + inner = _unwrap_stream_event(event) return inner if isinstance(inner, PermissionRequestEvent) else None def _tool_result_from(event: Any) -> ToolResultEvent | None: - inner = event.inner if isinstance(event, SubPipelineStreamEvent) else event + inner = _unwrap_stream_event(event) return inner if isinstance(inner, ToolResultEvent) else None -def _resolve_permission_future(request: PermissionRequestEvent, approved: bool) -> None: - if request.response_future is not None and not request.response_future.done(): - request.response_future.set_result(approved) +def _unwrap_stream_event(event: Any) -> Any: + while isinstance(event, SubPipelineStreamEvent): + event = event.inner + return event + + +def _resolve_permission_future(request: PermissionRequestEvent, approved: bool) -> bool: + future = request.response_future + if future is not None and not future.done(): + future.set_result(approved) + return True + return False + + +def _can_resolve_permission_future(request: PermissionRequestEvent) -> bool: + return request.response_future is not None and not request.response_future.done() + + +def _set_permission_approval(envelope: dict[str, Any], approved: bool) -> None: + permission = envelope.setdefault("permission", {}) + permission.update(_permission_approval_metadata(approved)) + + +def _permission_approval_metadata(approved: bool) -> dict[str, Any]: + return {"approved": approved, "decision": "allow_once" if approved else "deny"} def is_recovery_semantic_event(envelope: dict[str, Any]) -> bool: diff --git a/src/iac_code/acp/convert.py b/src/iac_code/acp/convert.py index c717b57c..c10d5b13 100644 --- a/src/iac_code/acp/convert.py +++ b/src/iac_code/acp/convert.py @@ -10,6 +10,7 @@ from iac_code.acp.state import TurnState, display_tool_title from iac_code.acp.types import ACPContentBlock from iac_code.i18n import _ +from iac_code.services.permissions.audit import build_input_summary from iac_code.types.stream_events import ( CompactionEvent, ErrorEvent, @@ -212,15 +213,14 @@ def event_to_updates(self, event: StreamEvent) -> list[SessionUpdate]: tc_state = self._turn_state.get_tool_call(tool_use_id) if self._turn_state else None if tc_state is not None: tc_state.update_input(partial_json) - title = tc_state.title if tc_state else None update = acp.schema.ToolCallProgress( session_update="tool_call_update", tool_call_id=self.acp_tool_call_id(tool_use_id), status="pending", - content=[_text_tool_content(self._tool_inputs[tool_use_id])], + content=[_text_tool_content(_input_delta_summary_text(self._tool_inputs[tool_use_id]))], ) - if title: - update.title = title + if tc_state is not None: + update.title = display_tool_title(tc_state.tool_name) return [update] case ToolUseEndEvent(tool_use_id=tool_use_id, name=name, input=input): return [ @@ -229,7 +229,7 @@ def event_to_updates(self, event: StreamEvent) -> list[SessionUpdate]: tool_call_id=self.acp_tool_call_id(tool_use_id), title=display_tool_title(name), status="in_progress", - content=[_text_tool_content(str(input))], + content=[_text_tool_content(_input_summary_text(name, input))], ) ] case MCPProgressEvent( @@ -377,6 +377,15 @@ def _format_mcp_progress_text( return ": ".join(parts) +def _input_summary_text(tool_name: str, tool_input: dict[str, Any]) -> str: + summary = json.dumps(build_input_summary(tool_name, tool_input), ensure_ascii=False, sort_keys=True) + return _("Input summary: {summary}").format(summary=summary) + + +def _input_delta_summary_text(accumulated_input: str) -> str: + return _("Input received ({count} chars)").format(count=len(accumulated_input)) + + def _public_tool_result_text(value: Any) -> str: sanitized = sanitize_public_tool_output_data(value) if isinstance(sanitized, str): diff --git a/src/iac_code/acp/session.py b/src/iac_code/acp/session.py index 8b3c3892..0e595dfe 100644 --- a/src/iac_code/acp/session.py +++ b/src/iac_code/acp/session.py @@ -28,10 +28,18 @@ is_recalled_memory_message, ) from iac_code.commands.registry import PromptCommand +from iac_code.i18n import _ +from iac_code.services.permissions.audit import ( + build_input_summary, + build_prompt_tool_input, + emit_permission_boundary_audit, + is_permission_audit_non_read_only, + should_fail_closed_permission_audit, +) from iac_code.services.telemetry import use_session_id from iac_code.state.app_state import lookup_permission, record_permission from iac_code.types.permissions import PermissionDecision -from iac_code.types.stream_events import PermissionRequestEvent +from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent from iac_code.utils.public_errors import public_error logger = logging.getLogger(__name__) @@ -83,6 +91,33 @@ def _history_tool_result_text(value: Any) -> str: return json.dumps(sanitized, ensure_ascii=False, default=str) +def _history_tool_input_text(tool_name: str, tool_input: dict[str, Any]) -> str: + if not tool_input: + return "" + return _display_tool_input_text(tool_name, tool_input) + + +def _display_tool_input_text(tool_name: str, tool_input: dict[str, Any]) -> str: + if tool_name != "aliyun_api": + tool_input_redacted = json.dumps( + build_prompt_tool_input(tool_input), + ensure_ascii=False, + sort_keys=True, + default=str, + ) + return _("Input: {input}").format(input=tool_input_redacted) + summary = json.dumps(build_input_summary(tool_name, tool_input), ensure_ascii=False, sort_keys=True) + return _("Input summary: {summary}").format(summary=summary) + + +def _permission_request_event(event: Any) -> PermissionRequestEvent | None: + if isinstance(event, PermissionRequestEvent): + return event + if isinstance(event, SubPipelineStreamEvent): + return _permission_request_event(event.inner) + return None + + def _history_message_to_updates( msg: Message, *, @@ -179,7 +214,7 @@ def _history_message_to_updates( status="pending", ) ) - input_text = json.dumps(block.input, ensure_ascii=False) if block.input else "" + input_text = _history_tool_input_text(block.name, block.input) updates.append( acp.schema.ToolCallProgress( session_update="tool_call_update", @@ -407,10 +442,11 @@ async def _run() -> None: logger.debug("Prompt started, session_id=%s, turn_id=%s", self.id, turn_id) with use_session_id(self.id): async for event in stream_factory(): - if isinstance(event, PermissionRequestEvent): - allowed = await self._request_permission(event) - if event.response_future is not None and not event.response_future.done(): - event.response_future.set_result(allowed) + permission_event = _permission_request_event(event) + if permission_event is not None: + allowed = await self._request_permission(permission_event) + if permission_event.response_future is not None and not permission_event.response_future.done(): + permission_event.response_future.set_result(allowed) continue for update in converter.event_to_updates(event): @@ -545,10 +581,41 @@ def _apply_rule(self, tool_name: str, rules_str: str, behavior: str) -> None: async def _request_permission(self, event: PermissionRequestEvent) -> bool: tool_name = event.tool_name + is_non_read_only = is_permission_audit_non_read_only(event) + + def _rule_from_option(option_id: str, prefix: str) -> str | None: + rules_str = option_id[len(prefix) :] + if not rules_str: + return None + return ", ".join(f"{tool_name}({rule.strip()})" for rule in rules_str.split(",") if rule.strip()) + + def _prompt_option_reason(option_id: str) -> str: + if option_id.startswith(_PREFIX_ALLOW_RULE): + return _PREFIX_ALLOW_RULE.rstrip(":") + if option_id.startswith(_PREFIX_DENY_RULE): + return _PREFIX_DENY_RULE.rstrip(":") + return option_id # Check permission cache first; helper marks the entry as recently-used. cached = lookup_permission(self._permission_cache, tool_name) + if cached == "always_allow" and tool_name == "aliyun_api" and is_non_read_only: + self._permission_cache.pop(tool_name, None) + cached = None + cached_audit_ok = True + if cached in ("always_allow", "always_deny"): + cached_audit_ok = emit_permission_boundary_audit( + event, + session_id=self.id, + decision="allow" if cached == "always_allow" else "deny", + scope="tool_cache", + source="acp_tool_cache", + rule_source="tool_cache", + reason_type="tool_cache", + reason_detail=cached, + ) if cached == "always_allow": + if not cached_audit_ok and should_fail_closed_permission_audit(event, "allow"): + return False logger.debug("Permission auto-allowed for tool %s (cached)", tool_name) return True if cached == "always_deny": @@ -568,7 +635,7 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: options: list[acp.schema.PermissionOption] = [ acp.schema.PermissionOption( option_id=_OPTION_ALLOW_ONCE, - name="Allow once", + name=_("Allow once"), kind="allow_once", ), ] @@ -578,7 +645,7 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: options.append( acp.schema.PermissionOption( option_id=_PREFIX_ALLOW_RULE + rules_display, - name='Always allow "{}" (this session)'.format(rules_display), + name=_('Always allow "{rule}" (this session)').format(rule=rules_display), kind="allow_always", ) ) @@ -586,7 +653,7 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: options.append( acp.schema.PermissionOption( option_id=_OPTION_ALLOW_ALWAYS, - name="Always allow this tool", + name=_("Always allow this tool"), kind="allow_always", ) ) @@ -594,7 +661,7 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: options.append( acp.schema.PermissionOption( option_id=_OPTION_REJECT_ONCE, - name="Reject once", + name=_("Reject once"), kind="reject_once", ) ) @@ -604,7 +671,7 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: options.append( acp.schema.PermissionOption( option_id=_PREFIX_DENY_RULE + rules_display, - name='Always deny "{}" (this session)'.format(rules_display), + name=_('Always deny "{rule}" (this session)').format(rule=rules_display), kind="reject_always", ) ) @@ -612,16 +679,39 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: options.append( acp.schema.PermissionOption( option_id=_OPTION_REJECT_ALWAYS, - name="Always reject this tool", + name=_("Always reject this tool"), kind="reject_always", ), ) + offered_option_ids = {option.option_id for option in options} # Build content with command details and suggested rule. tool_title = display_tool_title(tool_name) - content_text = "Approve tool call: {}\nInput: {}".format(tool_title, event.tool_input) + if tool_name == "aliyun_api": + input_summary = json.dumps( + build_input_summary(tool_name, event.tool_input), + ensure_ascii=False, + sort_keys=True, + ) + content_text = _("Approve tool call: {tool}\nInput summary: {summary}").format( + tool=tool_title, + summary=input_summary, + ) + else: + tool_input_redacted = json.dumps( + build_prompt_tool_input(event.tool_input), + ensure_ascii=False, + sort_keys=True, + default=str, + ) + content_text = _("Approve tool call: {tool}\nInput: {input}").format( + tool=tool_title, + input=tool_input_redacted, + ) if suggestions: - content_text += "\nSuggested rule: {}".format(",".join(s.rule_content for s in suggestions)) + content_text += "\n" + _("Suggested rule: {rule}").format( + rule=",".join(s.rule_content for s in suggestions) + ) response = await self._conn.request_permission( options, @@ -643,12 +733,45 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: # Interpret the outcome and update permission state. if isinstance(response.outcome, acp.schema.AllowedOutcome): - option_id = response.outcome.option_id + option_id = response.outcome.option_id or _OPTION_ALLOW_ONCE + if option_id not in offered_option_ids: + emit_permission_boundary_audit( + event, + session_id=self.id, + decision="deny", + scope="once", + source="acp_prompt", + reason_type="invalid_option", + reason_detail="invalid_option", + ) + return False + audit_scope = "once" + audit_rule = None + cache_decision: PermissionDecision | None = None + allow_rules_str: str | None = None if option_id == _OPTION_ALLOW_ALWAYS: - self._cache_permission(tool_name, "always_allow") + cache_decision = "always_allow" + audit_scope = "tool_cache" elif option_id and option_id.startswith(_PREFIX_ALLOW_RULE): - rules_str = option_id[len(_PREFIX_ALLOW_RULE) :] - self._apply_rule(tool_name, rules_str, "allow") + allow_rules_str = option_id[len(_PREFIX_ALLOW_RULE) :] + audit_scope = "session_rule" + audit_rule = _rule_from_option(option_id, _PREFIX_ALLOW_RULE) + audit_ok = emit_permission_boundary_audit( + event, + session_id=self.id, + decision="allow", + scope=audit_scope, + source="acp_prompt", + reason_type="prompt_selection", + reason_detail=_prompt_option_reason(option_id), + rule=audit_rule, + ) + if not audit_ok and should_fail_closed_permission_audit(event, "allow"): + return False + if cache_decision is not None: + self._cache_permission(tool_name, cache_decision) + if allow_rules_str is not None: + self._apply_rule(tool_name, allow_rules_str, "allow") return True # DeniedOutcome — parse option_id from meta or direct field. @@ -657,12 +780,30 @@ async def _request_permission(self, event: PermissionRequestEvent) -> bool: if option_id is None: resp_meta = getattr(response, "field_meta", None) or {} option_id = resp_meta.get("option_id") + option_id = option_id or _OPTION_REJECT_ONCE + if option_id not in offered_option_ids: + option_id = _OPTION_REJECT_ONCE + audit_scope = "once" + audit_rule = None if option_id == _OPTION_REJECT_ALWAYS: self._cache_permission(tool_name, "always_deny") + audit_scope = "tool_cache" elif option_id and option_id.startswith(_PREFIX_DENY_RULE): rules_str = option_id[len(_PREFIX_DENY_RULE) :] self._apply_rule(tool_name, rules_str, "deny") + audit_scope = "session_rule" + audit_rule = _rule_from_option(option_id, _PREFIX_DENY_RULE) + emit_permission_boundary_audit( + event, + session_id=self.id, + decision="deny", + scope=audit_scope, + source="acp_prompt", + reason_type="prompt_selection", + reason_detail=_prompt_option_reason(option_id), + rule=audit_rule, + ) return False diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 89b73ccd..7ca48cf1 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -17,10 +17,19 @@ from iac_code.agent.message import ContentBlock, TextBlock, ThinkingBlock, ToolResultBlock, ToolUseBlock from iac_code.i18n import _ from iac_code.services.context_manager import ContextManager +from iac_code.services.permissions.audit import ( + PermissionAuditRecord, + build_input_summary, + emit_permission_audit, + is_routine_read_only_allow, + permission_audit_operation, + redacted_tool_input_for_settings, +) from iac_code.services.session_usage import SessionUsageStore, SessionUsageTotals from iac_code.tools.base import ToolContext, ToolRegistry, ToolResult from iac_code.tools.result_storage import ResultStorage from iac_code.tools.tool_executor import ToolCallRequest, ToolExecutor +from iac_code.types.permissions import PermissionAuditSettings, PermissionResult from iac_code.types.stream_events import ( CompactionEvent, MessageEndEvent, @@ -93,6 +102,39 @@ def _with_trusted_read_directories(permission_context: Any, directories: list[st return replace(permission_context, trusted_read_directories=trusted_read_directories) +def _emit_no_prompt_permission_audit( + *, + session_id: str, + request: ToolCallRequest, + permission: PermissionResult, + decision: Literal["allow", "deny"], + settings: PermissionAuditSettings | None, +) -> bool: + audit = permission.audit + if audit is None or is_routine_read_only_allow(decision, audit): + return True + + result = emit_permission_audit( + PermissionAuditRecord( + session_id=session_id, + tool_name=request.name, + tool_use_id=request.id, + decision=decision, + scope=audit.scope, + source=audit.source, + rule_source=audit.rule_source, + rule=audit.rule, + reason_type=audit.reason_type, + reason_detail=audit.reason_detail, + operation=permission_audit_operation(audit), + input_summary=build_input_summary(request.name, request.input), + tool_input_redacted=redacted_tool_input_for_settings(request.input, settings), + ), + settings=settings, + ) + return result is not False + + def _filter_recalled_memory_content(content: str, selected_files: list[str]) -> str: keep = [_normalize_memory_filename(filename) for filename in selected_files] keep = [filename for filename in keep if filename] @@ -975,10 +1017,33 @@ async def _run_streaming_inner( else: permission = await tool.check_permissions(request.input, {"cwd": context.cwd}) + audit_context = { + "session_id": self._session_id, + "settings": perm_ctx.audit_settings if perm_ctx is not None else None, + "metadata": permission.audit, + } + if permission.behavior == "allow": + audit_ok = _emit_no_prompt_permission_audit( + session_id=self._session_id, + request=request, + permission=permission, + decision="allow", + settings=audit_context["settings"], + ) + if not audit_ok: + denied_results.append((request, ToolResult.error(_("Permission denied.")))) + continue allowed_requests.append(request) continue if permission.behavior == "deny": + _emit_no_prompt_permission_audit( + session_id=self._session_id, + request=request, + permission=permission, + decision="deny", + settings=audit_context["settings"], + ) msg = permission.message or _("Permission denied.") denied_results.append((request, ToolResult.error(msg))) continue @@ -990,6 +1055,7 @@ async def _run_streaming_inner( tool_use_id=request.id, response_future=response_future, permission_result=permission, + audit_context=audit_context, ) try: approved = await asyncio.shield(response_future) diff --git a/src/iac_code/agent/agent_tool.py b/src/iac_code/agent/agent_tool.py index b1748d10..271bf888 100644 --- a/src/iac_code/agent/agent_tool.py +++ b/src/iac_code/agent/agent_tool.py @@ -9,6 +9,7 @@ from iac_code.agent.agent_types import filter_tools, get_agent_definition, get_builtin_agents from iac_code.i18n import _, ngettext +from iac_code.services.permissions.audit import emit_auto_permission_audit from iac_code.tools.base import Tool, ToolContext, ToolResult @@ -75,6 +76,12 @@ async def run_sub_agent( async for event in sub_loop.run_streaming(prompt): if isinstance(event, PermissionRequestEvent): if event.response_future is not None and not event.response_future.done(): + emit_auto_permission_audit( + event, + decision="deny", + scope="auto_deny", + source="agent_tool_auto_deny", + ) event.response_future.set_result(False) continue if isinstance(event, TextDeltaEvent): diff --git a/src/iac_code/cli/headless.py b/src/iac_code/cli/headless.py index 42e1a258..66f8fd19 100644 --- a/src/iac_code/cli/headless.py +++ b/src/iac_code/cli/headless.py @@ -20,6 +20,9 @@ from iac_code.cli.output_formats import OutputFormat, create_writer from iac_code.i18n import _ from iac_code.providers.manager import ProviderNotConfiguredError +from iac_code.services.permissions.audit import emit_auto_permission_audit, is_aliyun_api_non_read_only_permission_event +from iac_code.services.telemetry import graceful_shutdown, log_event +from iac_code.services.telemetry.names import Events from iac_code.types.stream_events import ( ErrorEvent, MCPProgressEvent, @@ -28,6 +31,7 @@ StackInstancesProgressEvent, StackProgressEvent, SubAgentToolEvent, + SubPipelineStreamEvent, ToolResultEvent, ToolUseStartEvent, ) @@ -94,6 +98,14 @@ def _format_mcp_progress(event: MCPProgressEvent) -> str: return ": ".join(parts) +def _permission_request_event(event: Any) -> PermissionRequestEvent | None: + if isinstance(event, PermissionRequestEvent): + return event + if isinstance(event, SubPipelineStreamEvent): + return _permission_request_event(event.inner) + return None + + class HeadlessRunner: """Run a single prompt headlessly, auto-approving all permission requests.""" @@ -171,9 +183,6 @@ def _print_mcp_config_warnings(self) -> None: async def run(self, prompt: str) -> int: """Execute a single prompt to completion and return an exit code.""" - from iac_code.services.telemetry import graceful_shutdown, log_event - from iac_code.services.telemetry.names import Events - started = time.monotonic() start_background_housekeeping() @@ -188,19 +197,19 @@ async def run(self, prompt: str) -> int: self._print_mcp_config_warnings() async for event in agent_loop.run_streaming(prompt): self._print_mcp_config_warnings() - if isinstance(event, PermissionRequestEvent): - if event.response_future is not None: - from iac_code.services.telemetry import log_event - from iac_code.services.telemetry.names import Events - - log_event( - Events.TOOL_USE_GRANTED_IN_PROMPT, - { - "tool_name": event.tool_name, - "scope": "once", - }, + permission_event = _permission_request_event(event) + if permission_event is not None: + if permission_event.response_future is not None and not permission_event.response_future.done(): + approved = not is_aliyun_api_non_read_only_permission_event(permission_event) + audit_ok = emit_auto_permission_audit( + permission_event, + decision="allow" if approved else "deny", + scope="auto_approve" if approved else "auto_deny", + source="headless_auto_approve" if approved else "headless_auto_deny", ) - event.response_future.set_result(True) + if approved and not audit_ok: + approved = False + permission_event.response_future.set_result(approved) continue if isinstance(event, ErrorEvent): diff --git a/src/iac_code/cli/output_formats.py b/src/iac_code/cli/output_formats.py index af973b24..b39a9a1f 100644 --- a/src/iac_code/cli/output_formats.py +++ b/src/iac_code/cli/output_formats.py @@ -12,11 +12,16 @@ from typing import IO, Any from iac_code.a2a.artifacts import sanitize_public_tool_output_data +from iac_code.services.permissions.audit import build_input_summary from iac_code.types.stream_events import ( ErrorEvent, MessageEndEvent, + PermissionRequestEvent, StreamEvent, + SubAgentToolEvent, + SubPipelineStreamEvent, TextDeltaEvent, + ToolInputDeltaEvent, ToolResultEvent, ToolUseEndEvent, ToolUseStartEvent, @@ -52,6 +57,56 @@ def _sanitize_public_value(value: Any) -> Any: return value +def _stream_json_event_data(event: StreamEvent) -> dict[str, Any]: + if isinstance(event, PermissionRequestEvent): + return { + "input_summary": build_input_summary(event.tool_name, event.tool_input), + "tool_name": event.tool_name, + "tool_use_id": event.tool_use_id, + "type": event.type, + } + if isinstance(event, ToolInputDeltaEvent): + return { + "partial_json_length": len(event.partial_json), + "tool_use_id": event.tool_use_id, + "type": event.type, + } + if isinstance(event, ToolUseEndEvent): + return { + "input_summary": build_input_summary(event.name, event.input), + "name": event.name, + "tool_use_id": event.tool_use_id, + "type": event.type, + } + if isinstance(event, SubAgentToolEvent): + return { + "child_input_summary": build_input_summary(event.child_tool_name, event.child_tool_input), + "child_tool_name": event.child_tool_name, + "is_done": event.is_done, + "is_error": event.is_error, + "parent_tool_use_id": event.parent_tool_use_id, + "type": event.type, + } + if isinstance(event, SubPipelineStreamEvent): + return { + "candidate_index": event.candidate_index, + "inner": _stream_json_event_data(event.inner), + "sub_pipeline_id": event.sub_pipeline_id, + "type": event.type, + } + + data = dataclasses.asdict(event) + if isinstance(event, ErrorEvent): + data["error"] = sanitize_public_text(event.error) + elif isinstance(event, ToolResultEvent): + if data.get("metadata") is None: + data.pop("metadata", None) + elif "metadata" in data: + data["metadata"] = _public_tool_metadata(event, data["metadata"]) + data["result"] = _public_tool_result(event) + return data + + class TextWriter: """Writes only assistant text content to the output stream. @@ -98,7 +153,9 @@ def handle(self, event: StreamEvent) -> None: elif isinstance(event, ToolUseStartEvent): self._tool_uses.setdefault(event.tool_use_id, {})["name"] = event.name elif isinstance(event, ToolUseEndEvent): - self._tool_uses.setdefault(event.tool_use_id, {})["input"] = event.input + self._tool_uses.setdefault(event.tool_use_id, {})["input_summary"] = build_input_summary( + event.name, event.input + ) elif isinstance(event, ToolResultEvent): entry = self._tool_uses.setdefault(event.tool_use_id, {}) entry["result"] = _public_tool_result(event) @@ -141,15 +198,7 @@ def __init__(self, stream: IO[str] | None = None) -> None: self._stream = stream or sys.stdout def handle(self, event: StreamEvent) -> None: - data = dataclasses.asdict(event) - if isinstance(event, ErrorEvent): - data["error"] = sanitize_public_text(event.error) - elif isinstance(event, ToolResultEvent): - if data.get("metadata") is None: - data.pop("metadata", None) - elif "metadata" in data: - data["metadata"] = _public_tool_metadata(event, data["metadata"]) - data["result"] = _public_tool_result(event) + data = _stream_json_event_data(event) self._stream.write(json.dumps(data, ensure_ascii=False, default=str)) self._stream.write("\n") self._stream.flush() diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 41e5fc6a..65c2aa6a 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -182,6 +182,17 @@ msgstr "Aufgabe fehlgeschlagen." msgid "MCP {server}:{tool}" msgstr "MCP {server}:{tool}" +#: src/iac_code/acp/convert.py src/iac_code/acp/session.py +#: src/iac_code/ui/renderer.py +#, python-brace-format +msgid "Input summary: {summary}" +msgstr "Eingabezusammenfassung: {summary}" + +#: src/iac_code/acp/convert.py +#, python-brace-format +msgid "Input received ({count} chars)" +msgstr "Eingabe empfangen ({count} Zeichen)" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Sitzung nicht gefunden" @@ -196,6 +207,60 @@ msgstr "Der Sitzungsname ist mehrdeutig. Kandidaten: {candidates}" msgid "Session belongs to another project. Run: {hint}" msgstr "Die Sitzung gehört zu einem anderen Projekt. Ausführen: {hint}" +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Input: {input}" +msgstr "Eingabe: {input}" + +#: src/iac_code/acp/session.py +msgid "Allow once" +msgstr "Einmal zulassen" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always allow \"{rule}\" (this session)" +msgstr "Immer \"{rule}\" zulassen (diese Sitzung)" + +#: src/iac_code/acp/session.py +msgid "Always allow this tool" +msgstr "Dieses Tool immer zulassen" + +#: src/iac_code/acp/session.py +msgid "Reject once" +msgstr "Einmal ablehnen" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always deny \"{rule}\" (this session)" +msgstr "Immer \"{rule}\" ablehnen (diese Sitzung)" + +#: src/iac_code/acp/session.py +msgid "Always reject this tool" +msgstr "Dieses Tool immer ablehnen" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input summary: {summary}" +msgstr "" +"Tool-Aufruf genehmigen: {tool}\n" +"Eingabezusammenfassung: {summary}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input: {input}" +msgstr "" +"Tool-Aufruf genehmigen: {tool}\n" +"Eingabe: {input}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Suggested rule: {rule}" +msgstr "Vorgeschlagene Regel: {rule}" + #: src/iac_code/acp/slash_registry.py #, python-brace-format msgid "" @@ -3509,6 +3574,7 @@ msgstr "Übereinstimmende Abfrageregel(n): {}" #: src/iac_code/services/permissions/pipeline.py src/iac_code/tools/base.py #: src/iac_code/tools/bash/bash_tool.py +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Allow {}?" msgstr "{} erlauben?" @@ -4135,6 +4201,11 @@ msgstr "IMPORT FEHLGESCHLAGEN" msgid "Aliyun API" msgstr "Aliyun API" +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py +#, python-brace-format +msgid "matched {behavior} rule: {rule}" +msgstr "Übereinstimmende {behavior}-Regel: {rule}" + #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 8abbf84d..f9f4e1df 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -183,6 +183,17 @@ msgstr "La tarea falló." msgid "MCP {server}:{tool}" msgstr "MCP {server}:{tool}" +#: src/iac_code/acp/convert.py src/iac_code/acp/session.py +#: src/iac_code/ui/renderer.py +#, python-brace-format +msgid "Input summary: {summary}" +msgstr "Resumen de entrada: {summary}" + +#: src/iac_code/acp/convert.py +#, python-brace-format +msgid "Input received ({count} chars)" +msgstr "Entrada recibida ({count} caracteres)" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Sesión no encontrada" @@ -197,6 +208,60 @@ msgstr "El nombre de sesión es ambiguo. Sesiones candidatas: {candidates}" msgid "Session belongs to another project. Run: {hint}" msgstr "La sesión pertenece a otro proyecto. Ejecuta: {hint}" +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Input: {input}" +msgstr "Entrada: {input}" + +#: src/iac_code/acp/session.py +msgid "Allow once" +msgstr "Permitir una vez" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always allow \"{rule}\" (this session)" +msgstr "Permitir siempre \"{rule}\" (esta sesión)" + +#: src/iac_code/acp/session.py +msgid "Always allow this tool" +msgstr "Permitir siempre esta herramienta" + +#: src/iac_code/acp/session.py +msgid "Reject once" +msgstr "Rechazar una vez" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always deny \"{rule}\" (this session)" +msgstr "Rechazar siempre \"{rule}\" (esta sesión)" + +#: src/iac_code/acp/session.py +msgid "Always reject this tool" +msgstr "Rechazar siempre esta herramienta" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input summary: {summary}" +msgstr "" +"Aprobar llamada de herramienta: {tool}\n" +"Resumen de entrada: {summary}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input: {input}" +msgstr "" +"Aprobar llamada de herramienta: {tool}\n" +"Entrada: {input}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Suggested rule: {rule}" +msgstr "Regla sugerida: {rule}" + #: src/iac_code/acp/slash_registry.py #, python-brace-format msgid "" @@ -3498,6 +3563,7 @@ msgstr "Regla(s) de consulta coincidente(s): {}" #: src/iac_code/services/permissions/pipeline.py src/iac_code/tools/base.py #: src/iac_code/tools/bash/bash_tool.py +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Allow {}?" msgstr "¿Permitir {}?" @@ -4121,6 +4187,11 @@ msgstr "Importación fallida" msgid "Aliyun API" msgstr "Aliyun API" +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py +#, python-brace-format +msgid "matched {behavior} rule: {rule}" +msgstr "Regla {behavior} coincidente: {rule}" + #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 78cd713a..0e45b8bd 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -181,6 +181,17 @@ msgstr "La tâche a échoué." msgid "MCP {server}:{tool}" msgstr "MCP {server}:{tool}" +#: src/iac_code/acp/convert.py src/iac_code/acp/session.py +#: src/iac_code/ui/renderer.py +#, python-brace-format +msgid "Input summary: {summary}" +msgstr "Résumé de l'entrée : {summary}" + +#: src/iac_code/acp/convert.py +#, python-brace-format +msgid "Input received ({count} chars)" +msgstr "Entrée reçue ({count} caractères)" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Session introuvable" @@ -195,6 +206,60 @@ msgstr "Le nom de session est ambigu. Candidats : {candidates}" msgid "Session belongs to another project. Run: {hint}" msgstr "La session appartient à un autre projet. Exécutez : {hint}" +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Input: {input}" +msgstr "Entrée : {input}" + +#: src/iac_code/acp/session.py +msgid "Allow once" +msgstr "Autoriser une fois" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always allow \"{rule}\" (this session)" +msgstr "Toujours autoriser \"{rule}\" (cette session)" + +#: src/iac_code/acp/session.py +msgid "Always allow this tool" +msgstr "Toujours autoriser cet outil" + +#: src/iac_code/acp/session.py +msgid "Reject once" +msgstr "Refuser une fois" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always deny \"{rule}\" (this session)" +msgstr "Toujours refuser \"{rule}\" (cette session)" + +#: src/iac_code/acp/session.py +msgid "Always reject this tool" +msgstr "Toujours refuser cet outil" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input summary: {summary}" +msgstr "" +"Approuver l'appel d'outil : {tool}\n" +"Résumé de l'entrée : {summary}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input: {input}" +msgstr "" +"Approuver l'appel d'outil : {tool}\n" +"Entrée : {input}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Suggested rule: {rule}" +msgstr "Règle suggérée : {rule}" + #: src/iac_code/acp/slash_registry.py #, python-brace-format msgid "" @@ -3502,6 +3567,7 @@ msgstr "Règle(s) de demande correspondante(s) : {}" #: src/iac_code/services/permissions/pipeline.py src/iac_code/tools/base.py #: src/iac_code/tools/bash/bash_tool.py +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Allow {}?" msgstr "Autoriser {} ?" @@ -4128,6 +4194,11 @@ msgstr "ÉCHEC DE L’IMPORT" msgid "Aliyun API" msgstr "Aliyun API" +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py +#, python-brace-format +msgid "matched {behavior} rule: {rule}" +msgstr "Règle {behavior} correspondante : {rule}" + #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 858dac0a..460ea6cb 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -168,6 +168,17 @@ msgstr "タスクが失敗しました。" msgid "MCP {server}:{tool}" msgstr "MCP {server}:{tool}" +#: src/iac_code/acp/convert.py src/iac_code/acp/session.py +#: src/iac_code/ui/renderer.py +#, python-brace-format +msgid "Input summary: {summary}" +msgstr "入力サマリー: {summary}" + +#: src/iac_code/acp/convert.py +#, python-brace-format +msgid "Input received ({count} chars)" +msgstr "入力を受信しました({count} 文字)" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "セッションが見つかりません" @@ -182,6 +193,60 @@ msgstr "セッション名があいまいです。候補: {candidates}" msgid "Session belongs to another project. Run: {hint}" msgstr "セッションは別のプロジェクトに属しています。実行: {hint}" +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Input: {input}" +msgstr "入力: {input}" + +#: src/iac_code/acp/session.py +msgid "Allow once" +msgstr "今回のみ許可" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always allow \"{rule}\" (this session)" +msgstr "\"{rule}\" を常に許可(このセッション)" + +#: src/iac_code/acp/session.py +msgid "Always allow this tool" +msgstr "このツールを常に許可" + +#: src/iac_code/acp/session.py +msgid "Reject once" +msgstr "今回のみ拒否" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always deny \"{rule}\" (this session)" +msgstr "\"{rule}\" を常に拒否(このセッション)" + +#: src/iac_code/acp/session.py +msgid "Always reject this tool" +msgstr "このツールを常に拒否" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input summary: {summary}" +msgstr "" +"ツール呼び出しを承認: {tool}\n" +"入力サマリー: {summary}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input: {input}" +msgstr "" +"ツール呼び出しを承認: {tool}\n" +"入力: {input}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Suggested rule: {rule}" +msgstr "推奨ルール: {rule}" + #: src/iac_code/acp/slash_registry.py #, python-brace-format msgid "" @@ -3328,6 +3393,7 @@ msgstr "一致した確認ルール: {}" #: src/iac_code/services/permissions/pipeline.py src/iac_code/tools/base.py #: src/iac_code/tools/bash/bash_tool.py +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Allow {}?" msgstr "{} を許可しますか?" @@ -3941,6 +4007,11 @@ msgstr "インポート失敗" msgid "Aliyun API" msgstr "Aliyun API" +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py +#, python-brace-format +msgid "matched {behavior} rule: {rule}" +msgstr "一致した {behavior} ルール: {rule}" + #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 7788970b..cd237439 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -180,6 +180,17 @@ msgstr "A tarefa falhou." msgid "MCP {server}:{tool}" msgstr "MCP {server}:{tool}" +#: src/iac_code/acp/convert.py src/iac_code/acp/session.py +#: src/iac_code/ui/renderer.py +#, python-brace-format +msgid "Input summary: {summary}" +msgstr "Resumo da entrada: {summary}" + +#: src/iac_code/acp/convert.py +#, python-brace-format +msgid "Input received ({count} chars)" +msgstr "Entrada recebida ({count} caracteres)" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Sessão não encontrada" @@ -194,6 +205,60 @@ msgstr "O nome da sessão é ambíguo. Candidatas: {candidates}" msgid "Session belongs to another project. Run: {hint}" msgstr "A sessão pertence a outro projeto. Execute: {hint}" +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Input: {input}" +msgstr "Entrada: {input}" + +#: src/iac_code/acp/session.py +msgid "Allow once" +msgstr "Permitir uma vez" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always allow \"{rule}\" (this session)" +msgstr "Sempre permitir \"{rule}\" (esta sessão)" + +#: src/iac_code/acp/session.py +msgid "Always allow this tool" +msgstr "Sempre permitir esta ferramenta" + +#: src/iac_code/acp/session.py +msgid "Reject once" +msgstr "Rejeitar uma vez" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always deny \"{rule}\" (this session)" +msgstr "Sempre rejeitar \"{rule}\" (esta sessão)" + +#: src/iac_code/acp/session.py +msgid "Always reject this tool" +msgstr "Sempre rejeitar esta ferramenta" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input summary: {summary}" +msgstr "" +"Aprovar chamada de ferramenta: {tool}\n" +"Resumo da entrada: {summary}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input: {input}" +msgstr "" +"Aprovar chamada de ferramenta: {tool}\n" +"Entrada: {input}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Suggested rule: {rule}" +msgstr "Regra sugerida: {rule}" + #: src/iac_code/acp/slash_registry.py #, python-brace-format msgid "" @@ -3474,6 +3539,7 @@ msgstr "Regra(s) de consulta correspondente(s): {}" #: src/iac_code/services/permissions/pipeline.py src/iac_code/tools/base.py #: src/iac_code/tools/bash/bash_tool.py +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Allow {}?" msgstr "Permitir {}?" @@ -4094,6 +4160,11 @@ msgstr "Falha na importação" msgid "Aliyun API" msgstr "Aliyun API" +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py +#, python-brace-format +msgid "matched {behavior} rule: {rule}" +msgstr "Regra {behavior} correspondente: {rule}" + #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 6fdc7df1..46fbf6fd 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -166,6 +166,17 @@ msgstr "任务失败。" msgid "MCP {server}:{tool}" msgstr "MCP {server}:{tool}" +#: src/iac_code/acp/convert.py src/iac_code/acp/session.py +#: src/iac_code/ui/renderer.py +#, python-brace-format +msgid "Input summary: {summary}" +msgstr "输入摘要:{summary}" + +#: src/iac_code/acp/convert.py +#, python-brace-format +msgid "Input received ({count} chars)" +msgstr "已接收输入({count} 个字符)" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "未找到会话" @@ -180,6 +191,60 @@ msgstr "会话名称不唯一。候选项:{candidates}" msgid "Session belongs to another project. Run: {hint}" msgstr "会话属于另一个项目。请运行:{hint}" +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Input: {input}" +msgstr "输入:{input}" + +#: src/iac_code/acp/session.py +msgid "Allow once" +msgstr "仅允许一次" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always allow \"{rule}\" (this session)" +msgstr "始终允许 “{rule}”(本会话)" + +#: src/iac_code/acp/session.py +msgid "Always allow this tool" +msgstr "始终允许此工具" + +#: src/iac_code/acp/session.py +msgid "Reject once" +msgstr "拒绝一次" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Always deny \"{rule}\" (this session)" +msgstr "始终拒绝 “{rule}”(本会话)" + +#: src/iac_code/acp/session.py +msgid "Always reject this tool" +msgstr "始终拒绝此工具" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input summary: {summary}" +msgstr "" +"批准工具调用:{tool}\n" +"输入摘要:{summary}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "" +"Approve tool call: {tool}\n" +"Input: {input}" +msgstr "" +"批准工具调用:{tool}\n" +"输入:{input}" + +#: src/iac_code/acp/session.py +#, python-brace-format +msgid "Suggested rule: {rule}" +msgstr "建议规则:{rule}" + #: src/iac_code/acp/slash_registry.py #, python-brace-format msgid "" @@ -3285,6 +3350,7 @@ msgstr "匹配到询问规则:{}" #: src/iac_code/services/permissions/pipeline.py src/iac_code/tools/base.py #: src/iac_code/tools/bash/bash_tool.py +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Allow {}?" msgstr "允许 {}?" @@ -3889,6 +3955,11 @@ msgstr "导入失败" msgid "Aliyun API" msgstr "阿里云 API" +#: src/iac_code/tools/cloud/aliyun/aliyun_api.py +#, python-brace-format +msgid "matched {behavior} rule: {rule}" +msgstr "匹配到 {behavior} 规则:{rule}" + #: src/iac_code/tools/cloud/aliyun/aliyun_api.py #, python-brace-format msgid "Call succeeded (RequestId: {request_id})" diff --git a/src/iac_code/services/permissions/audit.py b/src/iac_code/services/permissions/audit.py new file mode 100644 index 00000000..ec77df02 --- /dev/null +++ b/src/iac_code/services/permissions/audit.py @@ -0,0 +1,1074 @@ +"""Permission audit logging and telemetry.""" + +from __future__ import annotations + +import hashlib +import os +import re +import stat +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal + +from loguru import logger + +from iac_code.config import get_config_dir +from iac_code.services.telemetry import log_event +from iac_code.services.telemetry.names import Events +from iac_code.types.permissions import MAX_PERMISSION_AUDIT_FILES, PermissionAuditSettings +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.public_errors import sanitize_public_text +from iac_code.utils.state_io import append_jsonl_rotating_locked + +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_SAFE_RULE_TEXT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.*:-]{0,127}$") +_SAFE_WRAPPED_RULE_TEXT = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\(([A-Za-z0-9][A-Za-z0-9_.*:-]{0,127})\)$") +_SAFE_PATHNAME_SHAPE = re.compile(r"^/(?:\{segment\}(?:/\{segment\})*)?$") +_FINGERPRINT = re.compile(r"^sha256:[0-9a-f]{16}$") +_SECRET_KEY_PARTS = ( + "accesskey", + "apikey", + "auth", + "authorization", + "cookie", + "credential", + "passphrase", + "privatekey", + "pwd", + "secret", + "session", + "signature", + "ststoken", + "token", + "password", + "passwd", +) +_TEXT_MAX_CHARS = 160 +_REDACTED_MAX_DEPTH = 16 +_REDACTED_MAX_FIELDS = 64 +_REDACTED_MAX_NODES = 512 +_TRUNCATED_FIELD = "_truncated" +_DISPLAY_TEXT_EDGE_CHARS = 160 +_DISPLAY_PRIORITY_FIELD_NAMES = ( + "command", + "cmd", + "path", + "file_path", + "action", + "product", + "params", + "body", + "method", + "pathname", + "region", + "region_id", + "cwd", + "timeout", + "content", + "input", + "query", + "url", +) +_PROMPT_RAW_FIELD_NAMES = frozenset(("command", "cmd", "path", "file_path")) +_SAFE_SHAPE_FIELD_NAMES = frozenset( + { + *_DISPLAY_PRIORITY_FIELD_NAMES, + _TRUNCATED_FIELD, + "fields", + "fields_truncated", + "field_count", + "tool_name", + "type", + "length", + "fingerprint", + "redacted", + "truncated", + "prefix", + "suffix", + "is_read_only", + "headers", + "payload", + "params_fields", + "params_field_count", + "params_field_shapes", + "params_fields_truncated", + "params_shape", + "body_fields", + "body_field_count", + "body_field_shapes", + "body_fields_truncated", + "body_shape", + "pathname_shape", + "pathname_fingerprint", + "product_fingerprint", + "action_fingerprint", + "region_fingerprint", + "style", + } +) +_OPERATION_ID_KEYS = ("product", "action", "region") +_OPERATION_FINGERPRINT_KEYS = ("product_fingerprint", "action_fingerprint", "region_fingerprint") +_SECRET_VALUE_PATTERN = r"""(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}|"(?:(?:\\.)|[^"\\])*"|'(?:(?:\\.)|[^'\\])*'|[^\s,;}]+)""" +_SECRET_KEY_PATTERN = ( + r"auth|authorization|cookie|credential|credentials|passphrase|password|passwd|private[-_]?key|pwd|" + r"secret|session|signature|token|x[-_]?api[-_]?key|api[-_]?key|" + r"sts[-_]?token|access[-_]?key(?:[-_]?(?:id|secret))?" +) +_SECRET_ASSIGNMENT = re.compile( + r"""(?ix) + (?P + [A-Za-z0-9_.-]* + (?:""" + + _SECRET_KEY_PATTERN + + r""") + [A-Za-z0-9_.-]* + ) + (?P\s*[:=]\s*) + """ + + _SECRET_VALUE_PATTERN +) +_QUOTED_SECRET_ASSIGNMENT = re.compile( + r"""(?ix) + (?P["']) + (?P + [A-Za-z0-9_.-]* + (?:""" + + _SECRET_KEY_PATTERN + + r""") + [A-Za-z0-9_.-]* + ) + (?P=quote) + (?P\s*:\s*) + """ + + _SECRET_VALUE_PATTERN +) +_SECRET_CLI_FLAG = re.compile( + r"""(?ix) + (?P^|[\s;&|([{]) + (?P + --?[A-Za-z0-9_.-]* + (?:""" + + _SECRET_KEY_PATTERN + + r""") + [A-Za-z0-9_.-]* + ) + (?P\s+|=) + """ + + _SECRET_VALUE_PATTERN + + r""" + """ +) +_SECRET_LABEL = re.compile( + r"(?i)\b(" + r"access[-_ ]?key(?:[-_ ]?(?:id|secret))?|" + r"x[-_ ]?api[-_ ]?key|api[-_ ]?key|" + r"secret|signature|token|password|authorization|sts[-_ ]?token|credential|cookie|private[-_ ]?key" + r")\b" +) +_PEM_PRIVATE_KEY_BLOCK = re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", + re.IGNORECASE | re.DOTALL, +) + + +@dataclass +class PermissionAuditRecord: + """A single sanitized permission audit event.""" + + session_id: str + tool_name: str + tool_use_id: str + decision: Literal["allow", "deny"] + scope: str + source: str + rule_source: str | None = None + reason_type: str | None = None + reason_detail: str | None = None + trigger_reason_type: str | None = None + rule: str | None = None + rule_fingerprint: str | None = None + operation: dict[str, Any] = field(default_factory=dict) + input_summary: dict[str, Any] = field(default_factory=dict) + tool_input_redacted: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + +def fingerprint_text(value: str) -> str: + """Return a short stable fingerprint for sensitive text.""" + + return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + +def _is_fingerprint(value: Any) -> bool: + return isinstance(value, str) and bool(_FINGERPRINT.fullmatch(value)) + + +def _is_secret_key(key: str) -> bool: + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + return any(part in normalized for part in _SECRET_KEY_PARTS) + + +def _is_redacted_shape(value: Any) -> bool: + if not isinstance(value, dict): + return False + if value == {"redacted": True}: + return True + value_type = value.get("type") + if not isinstance(value_type, str): + return False + keys = set(value) + if value_type == "str": + return ( + keys == {"type", "length", "fingerprint"} + and isinstance(value.get("length"), int) + and _is_fingerprint(value.get("fingerprint")) + ) + if value_type == "array": + return keys == {"type", "length"} and isinstance(value.get("length"), int) + if value_type == "object": + return keys == {"type", "truncated"} and value.get("truncated") is True + if value_type == "null": + return keys == {"type"} + return keys == {"type"} and value_type in {"bool", "int", "float", "complex", "bytes"} + + +def _truncated_object_shape() -> dict[str, Any]: + return {"type": "object", "truncated": True} + + +def _redacted_scalar_shape(value: Any) -> dict[str, Any]: + if isinstance(value, str): + return {"type": "str", "length": len(value), "fingerprint": fingerprint_text(value)} + if isinstance(value, list | tuple): + return {"type": "array", "length": len(value)} + if value is None: + return {"type": "null"} + return {"type": type(value).__name__} + + +def redact_value( + key: str, + value: Any, + *, + _depth: int = 0, + _budget: dict[str, int] | None = None, +) -> Any: + """Redact sensitive values while preserving non-sensitive container shape.""" + + if _budget is None: + _budget = {"nodes": _REDACTED_MAX_NODES} + if _budget["nodes"] <= 0: + return _truncated_object_shape() if isinstance(value, dict) else _redacted_scalar_shape(value) + _budget["nodes"] -= 1 + + if _is_secret_key(key): + return {"redacted": True} + if _is_redacted_shape(value): + return value + if isinstance(value, dict): + if _depth >= _REDACTED_MAX_DEPTH: + return _truncated_object_shape() + redacted: dict[str, Any] = {} + for index, (child_key, child_value) in enumerate(value.items()): + if index >= _REDACTED_MAX_FIELDS or _budget["nodes"] <= 0: + redacted[_TRUNCATED_FIELD] = _truncated_object_shape() + break + redacted[_safe_field_name(child_key)] = redact_value( + str(child_key), + child_value, + _depth=_depth + 1, + _budget=_budget, + ) + return redacted + return _redacted_scalar_shape(value) + + +def _display_value( + key: str, + value: Any, + *, + text_sanitizer=None, + _depth: int = 0, + _budget: dict[str, int] | None = None, + _seen: set[int] | None = None, +) -> Any: + if text_sanitizer is None: + text_sanitizer = sanitize_free_text + if _budget is None: + _budget = _shape_budget() + if _seen is None: + _seen = set() + if _budget["nodes"] <= 0: + return _truncated_object_shape() if isinstance(value, dict | list | tuple) else value + _budget["nodes"] -= 1 + if _is_secret_key(key): + return {"redacted": True} + if isinstance(value, dict): + if _depth >= _REDACTED_MAX_DEPTH or id(value) in _seen: + return _truncated_object_shape() + _seen.add(id(value)) + try: + displayed: dict[str, Any] = {} + for index, (child_key, child_value) in enumerate(_display_ordered_items(value)): + if index >= _REDACTED_MAX_FIELDS or _budget["nodes"] <= 0: + displayed[_TRUNCATED_FIELD] = _truncated_object_shape() + break + displayed[_display_field_name(child_key)] = _display_value( + str(child_key), + child_value, + text_sanitizer=text_sanitizer, + _depth=_depth + 1, + _budget=_budget, + _seen=_seen, + ) + return displayed + finally: + _seen.remove(id(value)) + if isinstance(value, list | tuple): + if _depth >= _REDACTED_MAX_DEPTH or id(value) in _seen: + return _truncated_object_shape() + _seen.add(id(value)) + try: + displayed_items: list[Any] = [] + for index, item in enumerate(value): + if index >= _REDACTED_MAX_FIELDS or _budget["nodes"] <= 0: + displayed_items.append(_truncated_object_shape()) + break + displayed_items.append( + _display_value( + key, + item, + text_sanitizer=text_sanitizer, + _depth=_depth + 1, + _budget=_budget, + _seen=_seen, + ) + ) + return displayed_items + finally: + _seen.remove(id(value)) + if isinstance(value, str): + return _display_text(value, text_sanitizer=text_sanitizer) + return value + + +def _display_ordered_items(value: dict[Any, Any]) -> list[tuple[Any, Any]]: + items: list[tuple[Any, Any]] = [] + emitted: set[Any] = set() + for key in _DISPLAY_PRIORITY_FIELD_NAMES: + if key in value: + items.append((key, value[key])) + emitted.add(key) + for item in value.items(): + if item[0] not in emitted: + items.append(item) + return items + + +def _display_text(text: str, *, text_sanitizer=None) -> str | dict[str, Any] | None: + if text_sanitizer is None: + text_sanitizer = sanitize_free_text + if len(text) <= _DISPLAY_TEXT_EDGE_CHARS: + return text_sanitizer(text) + sanitized = text_sanitizer(text, max_chars=max(len(text), _DISPLAY_TEXT_EDGE_CHARS * 2)) + if sanitized is None: + return None + return { + "type": "str", + "length": len(text), + "prefix": sanitized[:_DISPLAY_TEXT_EDGE_CHARS], + "suffix": sanitized[-_DISPLAY_TEXT_EDGE_CHARS:], + "truncated": True, + } + + +def build_display_tool_input(tool_input: dict[str, Any]) -> dict[str, Any]: + """Return tool input sanitized for permission prompts, preserving decision-critical values.""" + + display_value = _display_value("tool_input", tool_input) + return display_value if isinstance(display_value, dict) else {} + + +def build_prompt_tool_input(tool_input: dict[str, Any]) -> dict[str, Any]: + """Return protocol-safe prompt input while preserving critical decision fields.""" + + redacted = build_redacted_tool_input(tool_input) + prompt: dict[str, Any] = {} + for key in _DISPLAY_PRIORITY_FIELD_NAMES: + if key not in _PROMPT_RAW_FIELD_NAMES or key not in tool_input or _is_secret_key(key): + continue + prompt[key] = _display_value(key, tool_input[key], text_sanitizer=sanitize_prompt_text) + for key, value in redacted.items(): + if key in prompt: + continue + if len(prompt) >= _REDACTED_MAX_FIELDS and _TRUNCATED_FIELD not in prompt: + prompt[_TRUNCATED_FIELD] = _truncated_object_shape() + break + prompt[key] = value + return prompt + + +def build_redacted_tool_input(tool_input: dict[str, Any]) -> dict[str, Any]: + """Return tool input with sensitive values redacted.""" + + redacted = redact_value("tool_input", tool_input) + return redacted if isinstance(redacted, dict) else {} + + +def redacted_tool_input_for_settings( + tool_input: dict[str, Any], + settings: PermissionAuditSettings | None, +) -> dict[str, Any] | None: + """Return redacted tool input only when audit settings opt into it.""" + + if settings is None or not settings.include_tool_input: + return None + return build_redacted_tool_input(tool_input) + + +def sanitize_free_text(text: str | None, *, max_chars: int = _TEXT_MAX_CHARS) -> str | None: + """Redact obvious secrets from free text and cap its length.""" + + if text is None: + return None + sanitized = _PEM_PRIVATE_KEY_BLOCK.sub("[REDACTED]", text) + sanitized = sanitize_public_text(sanitized) + sanitized = re.sub(r"(?i)\bbearer\s+\S+", "bearer [REDACTED]", sanitized) + sanitized = _QUOTED_SECRET_ASSIGNMENT.sub(_redact_quoted_secret_assignment, sanitized) + sanitized = _SECRET_ASSIGNMENT.sub(_redact_secret_assignment, sanitized) + sanitized = _SECRET_LABEL.sub("[REDACTED]", sanitized) + return sanitized[:max_chars] + + +def sanitize_prompt_text(text: str | None, *, max_chars: int = _TEXT_MAX_CHARS) -> str | None: + """Redact secrets for permission prompts while preserving path context.""" + + if text is None: + return None + sanitized = _PEM_PRIVATE_KEY_BLOCK.sub("[REDACTED]", text) + sanitized = re.sub(r"(?i)\bbearer\s+\S+", "bearer [REDACTED]", sanitized) + sanitized = _SECRET_CLI_FLAG.sub(_redact_secret_cli_flag, sanitized) + sanitized = _QUOTED_SECRET_ASSIGNMENT.sub(_redact_quoted_secret_assignment, sanitized) + sanitized = _SECRET_ASSIGNMENT.sub(_redact_secret_assignment, sanitized) + return sanitized[:max_chars] + + +def _redact_secret_assignment(match: re.Match[str]) -> str: + return f"{match.group('key')}{match.group('separator')}[REDACTED]" + + +def _redact_quoted_secret_assignment(match: re.Match[str]) -> str: + quote = match.group("quote") + return f"{quote}{match.group('key')}{quote}{match.group('separator')}[REDACTED]" + + +def _redact_secret_cli_flag(match: re.Match[str]) -> str: + flag = match.group("flag") + separator = match.group("separator") + if separator.strip() == "" and not flag.startswith("-"): + return match.group(0) + return f"{match.group('prefix')}{flag}{separator}[REDACTED]" + + +def build_input_summary(tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]: + """Build a non-secret summary of tool input.""" + + if tool_name == "aliyun_api": + return _build_aliyun_api_summary(tool_input) + fields, truncated = _field_value_shapes(tool_input, _depth=0, _budget=_shape_budget()) + summary: dict[str, Any] = { + "tool_name": _safe_summary_text("tool_name", tool_name), + "fields": fields, + } + if truncated: + summary["fields_truncated"] = True + return {key: value for key, value in summary.items() if value is not None} + + +def is_permission_audit_non_read_only(event: Any) -> bool: + """Return whether a permission event metadata represents a non-read-only operation.""" + + metadata = _permission_audit_metadata(event) + return metadata is None or getattr(metadata, "is_read_only", None) is not True + + +def is_aliyun_api_non_read_only_metadata(tool_name: str, metadata: Any | None) -> bool: + """Return whether metadata represents a non-read-only Aliyun API operation.""" + + return tool_name == "aliyun_api" and (metadata is None or getattr(metadata, "is_read_only", None) is not True) + + +def is_aliyun_api_non_read_only_permission_event(event: Any) -> bool: + """Return whether a permission event is an unresolved non-read-only Aliyun API operation.""" + + return is_aliyun_api_non_read_only_metadata(str(getattr(event, "tool_name", "")), _permission_audit_metadata(event)) + + +def should_fail_closed_permission_audit(event: Any, decision: Literal["allow", "deny"]) -> bool: + """Return whether an audit failure should prevent the permission decision.""" + + return decision == "allow" + + +def is_routine_read_only_allow(decision: Literal["allow", "deny"], metadata: Any | None) -> bool: + """Return True for automatic read-only allow decisions that do not need audit rows.""" + + return ( + decision == "allow" + and metadata is not None + and getattr(metadata, "is_read_only", None) is True + and getattr(metadata, "scope", None) == "read_only" + ) + + +def emit_permission_boundary_audit( + event: Any, + *, + session_id: str = "", + decision: Literal["allow", "deny"], + scope: str, + source: str, + rule_source: str | None = None, + reason_type: str | None = None, + reason_detail: str | None = None, + rule: str | None = None, +) -> bool: + """Emit a prompt/cache boundary audit record for a permission request event. + + Prompt/cache boundaries represent explicit user or cached policy decisions, + so read-only events are emitted too; only automatic read-only allows are + skipped by no-prompt audit callers. + """ + + metadata = _permission_audit_metadata(event) + settings = _permission_audit_settings(event) + result = emit_permission_audit( + PermissionAuditRecord( + session_id=_permission_audit_session_id(event, fallback=session_id), + tool_name=event.tool_name, + tool_use_id=event.tool_use_id, + decision=decision, + scope=scope, + source=source, + rule_source=_boundary_rule_source(scope, rule_source=rule_source, metadata=metadata), + reason_type=reason_type if reason_type is not None else getattr(metadata, "reason_type", None), + reason_detail=reason_detail if reason_detail is not None else getattr(metadata, "reason_detail", None), + trigger_reason_type=_boundary_trigger_reason_type(reason_type=reason_type, metadata=metadata), + rule=rule if rule is not None else getattr(metadata, "rule", None), + operation=permission_audit_operation(metadata), + input_summary=build_input_summary(event.tool_name, event.tool_input), + tool_input_redacted=redacted_tool_input_for_settings(event.tool_input, settings), + ), + settings=settings, + ) + return result is not False + + +def _boundary_rule_source(scope: str, *, rule_source: str | None, metadata: Any | None) -> str | None: + if rule_source is not None: + return rule_source + if scope == "session_rule": + return "session" + if scope == "tool_cache": + return "tool_cache" + return getattr(metadata, "rule_source", None) + + +def _boundary_trigger_reason_type(*, reason_type: str | None, metadata: Any | None) -> str | None: + metadata_reason_type = getattr(metadata, "reason_type", None) + if reason_type is not None and reason_type != metadata_reason_type and isinstance(metadata_reason_type, str): + return metadata_reason_type + return None + + +def emit_auto_permission_audit( + event: Any, + *, + decision: Literal["allow", "deny"], + scope: str, + source: str, + session_id: str = "", +) -> bool: + """Emit an automatic boundary decision while preserving permission metadata.""" + + metadata = _permission_audit_metadata(event) + settings = _permission_audit_settings(event) + result = emit_permission_audit( + PermissionAuditRecord( + session_id=_permission_audit_session_id(event, fallback=session_id), + tool_name=event.tool_name, + tool_use_id=event.tool_use_id, + decision=decision, + scope=scope, + source=source, + rule_source=getattr(metadata, "rule_source", None), + reason_type=getattr(metadata, "reason_type", None) or source, + reason_detail=getattr(metadata, "reason_detail", None), + rule=getattr(metadata, "rule", None), + operation=permission_audit_operation(metadata), + input_summary=build_input_summary(event.tool_name, event.tool_input), + tool_input_redacted=redacted_tool_input_for_settings(event.tool_input, settings), + ), + settings=settings, + ) + return result is not False + + +def _permission_audit_context(event: Any) -> dict[str, Any]: + audit_context = getattr(event, "audit_context", None) or {} + return audit_context if isinstance(audit_context, dict) else {} + + +def _permission_audit_metadata(event: Any) -> Any | None: + return _permission_audit_context(event).get("metadata") + + +def permission_audit_operation(metadata: Any | None) -> dict[str, Any]: + """Return sanitized-ready operation metadata with read/write classification folded in.""" + + operation = dict(getattr(metadata, "operation", {}) or {}) + is_read_only = getattr(metadata, "is_read_only", None) + if isinstance(is_read_only, bool): + operation.setdefault("is_read_only", is_read_only) + return operation + + +def _permission_audit_settings(event: Any) -> PermissionAuditSettings | None: + settings = _permission_audit_context(event).get("settings") + return settings if isinstance(settings, PermissionAuditSettings) else None + + +def _permission_audit_session_id(event: Any, *, fallback: str) -> str: + session_id = _permission_audit_context(event).get("session_id") + return session_id if isinstance(session_id, str) else fallback + + +def emit_permission_audit(record: PermissionAuditRecord, settings: PermissionAuditSettings | None = None) -> bool: + """Write a permission audit row and emit a telemetry event.""" + + audit_settings = settings or PermissionAuditSettings() + max_files = _bounded_max_files(audit_settings.max_files) + row = _audit_row(record, include_tool_input=audit_settings.include_tool_input) + log_written = False + + try: + log_path = _log_path() + _ensure_existing_audit_log_files_private(log_path, max_files=max_files) + append_jsonl_rotating_locked( + log_path, + [row], + max_file_bytes=audit_settings.max_file_bytes, + max_files=max_files, + durable=True, + create_mode=0o600, + ) + _ensure_audit_log_files_private(log_path, max_files=max_files) + log_written = True + except Exception as exc: # pragma: no cover - defensive logging only + logger.warning("Failed to write permission audit log: {}", exc) + + if log_written or record.decision == "deny": + try: + log_event(_event_name(record), _telemetry_metadata(record)) + except Exception as exc: # pragma: no cover - defensive logging only + logger.warning("Failed to emit permission audit telemetry: {}", exc) + + return log_written + + +def _bounded_max_files(max_files: int) -> int: + if max_files < 1: + return 1 + return min(max_files, MAX_PERMISSION_AUDIT_FILES) + + +def _audit_row(record: PermissionAuditRecord, *, include_tool_input: bool = False) -> dict[str, Any]: + row: dict[str, Any] = { + "session_id": record.session_id, + "tool_name": record.tool_name, + "tool_use_id": record.tool_use_id, + "decision": record.decision, + "scope": record.scope, + "source": record.source, + "rule_source": _safe_reason_token(record.rule_source), + "reason_type": _safe_reason_token(record.reason_type), + "reason_detail": _safe_reason_detail(record), + "operation": _sanitize_operation_metadata(record.operation), + "input_summary": _sanitize_input_summary(record.input_summary), + "timestamp": record.timestamp, + } + safe_trigger_reason_type = _safe_reason_token(record.trigger_reason_type) + if safe_trigger_reason_type is not None: + row["trigger_reason_type"] = safe_trigger_reason_type + else: + row.pop("trigger_reason_type", None) + rule_fingerprint = _safe_rule_fingerprint(record) + if rule_fingerprint is not None: + row["rule_fingerprint"] = rule_fingerprint + else: + row.pop("rule_fingerprint", None) + safe_rule = _safe_rule_text(record.rule) + if safe_rule is not None: + row["rule"] = safe_rule + else: + row.pop("rule", None) + if include_tool_input and record.tool_input_redacted is not None: + row["tool_input_redacted"] = redact_value("tool_input", record.tool_input_redacted) + else: + row.pop("tool_input_redacted", None) + return {key: value for key, value in row.items() if value is not None} + + +def _safe_reason_detail(record: PermissionAuditRecord) -> str | None: + if record.reason_type == "rule": + return "matched permission rule" + return sanitize_free_text(record.reason_detail) + + +def _safe_rule_text(rule: str | None) -> str | None: + if rule is None: + return None + if _SAFE_RULE_TEXT.fullmatch(rule): + return rule + if ", " in rule: + parts = rule.split(", ") + if all(_safe_rule_text(part) == part for part in parts): + return rule + wrapped = _SAFE_WRAPPED_RULE_TEXT.fullmatch(rule) + if wrapped is not None: + return rule + return None + + +def _sanitize_operation_metadata(operation: dict[str, Any]) -> dict[str, Any]: + sanitized: dict[str, Any] = {} + for key in _OPERATION_ID_KEYS: + value = operation.get(key) + if isinstance(value, str): + if _SAFE_ID.fullmatch(value): + sanitized[key] = value + else: + sanitized[f"{key}_fingerprint"] = fingerprint_text(value) + + for key in _OPERATION_FINGERPRINT_KEYS: + value = operation.get(key) + if _is_fingerprint(value): + sanitized[key] = value + + is_read_only = operation.get("is_read_only") + if isinstance(is_read_only, bool): + sanitized["is_read_only"] = is_read_only + + return sanitized + + +def _telemetry_metadata(record: PermissionAuditRecord) -> dict[str, Any]: + metadata: dict[str, Any] = { + "tool_name": record.tool_name, + "decision": record.decision, + "scope": record.scope, + "source": record.source, + } + for key in ("rule_source", "reason_type"): + value = getattr(record, key) + safe_value = _safe_reason_token(value) + if safe_value is not None: + metadata[key] = safe_value + + trigger_reason_type = _safe_reason_token(record.trigger_reason_type) + if trigger_reason_type is not None: + metadata["trigger_reason_type"] = trigger_reason_type + + for key in ("product", "action", "region"): + value = record.operation.get(key) + if isinstance(value, str): + if _SAFE_ID.fullmatch(value): + metadata[key] = value + else: + metadata[f"{key}_fingerprint"] = fingerprint_text(value) + + for key in ("product_fingerprint", "action_fingerprint", "region_fingerprint"): + value = record.operation.get(key) + if _is_fingerprint(value): + metadata[key] = value + + is_read_only = record.operation.get("is_read_only") + if isinstance(is_read_only, bool): + metadata["is_read_only"] = is_read_only + + rule_fingerprint = _safe_rule_fingerprint(record) + if rule_fingerprint is not None: + metadata["rule_fingerprint"] = rule_fingerprint + + return metadata + + +def _safe_rule_fingerprint(record: PermissionAuditRecord) -> str | None: + if _is_fingerprint(record.rule_fingerprint): + return record.rule_fingerprint + if record.rule: + return fingerprint_text(record.rule) + return None + + +def _build_aliyun_api_summary(tool_input: dict[str, Any]) -> dict[str, Any]: + summary: dict[str, Any] = {"tool_name": "aliyun_api"} + for input_key, output_key in ( + ("product", "product"), + ("action", "action"), + ("region_id", "region"), + ("region", "region"), + ("style", "style"), + ("method", "method"), + ): + if input_key in tool_input and output_key not in summary: + _add_safe_or_fingerprint(summary, output_key, tool_input[input_key]) + + pathname = tool_input.get("pathname") + if isinstance(pathname, str): + summary["pathname_shape"] = _pathname_shape(pathname) + summary["pathname_fingerprint"] = fingerprint_text(pathname) + + for key in ("params", "body"): + value = tool_input.get(key) + if isinstance(value, dict): + field_shapes, truncated = _field_value_shapes( + value, + _depth=0, + _budget=_shape_budget(), + fingerprint_keys=True, + ) + summary[f"{key}_field_count"] = len(value) + summary[f"{key}_fields"] = sorted(field_shapes) + summary[f"{key}_field_shapes"] = field_shapes + if truncated: + summary[f"{key}_fields_truncated"] = True + elif value is not None: + summary[f"{key}_shape"] = _value_shape(value, _budget=_shape_budget()) + + return summary + + +def _add_safe_or_fingerprint(summary: dict[str, Any], key: str, value: Any) -> None: + if isinstance(value, str) and _SAFE_ID.fullmatch(value): + summary[key] = value + elif value is not None: + summary[f"{key}_fingerprint"] = fingerprint_text(str(value)) + + +def _pathname_shape(pathname: str) -> str: + parts = [part for part in pathname.split("/") if part] + return "/" + "/".join("{segment}" for _part in parts) + + +def _shape_budget() -> dict[str, int]: + return {"nodes": _REDACTED_MAX_NODES} + + +def _field_value_shapes( + value: dict[Any, Any], + *, + _depth: int, + _budget: dict[str, int], + fingerprint_keys: bool = False, +) -> tuple[dict[str, Any], bool]: + fields: dict[str, Any] = {} + truncated = False + for index, (field_name, field_value) in enumerate(value.items()): + if index >= _REDACTED_MAX_FIELDS or _budget["nodes"] <= 0: + truncated = True + break + safe_name = fingerprint_text(str(field_name)) if fingerprint_keys else _safe_field_name(field_name) + fields[safe_name] = _value_shape( + field_value, + _depth=_depth + 1, + _budget=_budget, + fingerprint_keys=fingerprint_keys, + ) + return fields, truncated + + +def _summary_scalar_shape(value: Any) -> dict[str, Any]: + if isinstance(value, list | tuple): + return {"type": "array", "length": len(value)} + if value is None: + return {"type": "null"} + return {"type": type(value).__name__} + + +def _value_shape( + value: Any, + *, + _depth: int = 0, + _budget: dict[str, int] | None = None, + fingerprint_keys: bool = False, +) -> dict[str, Any]: + if _budget is None: + _budget = _shape_budget() + if _budget["nodes"] <= 0: + return _truncated_object_shape() if isinstance(value, dict) else _summary_scalar_shape(value) + _budget["nodes"] -= 1 + if isinstance(value, dict): + if _depth >= _REDACTED_MAX_DEPTH: + return _truncated_object_shape() + fields, truncated = _field_value_shapes( + value, + _depth=_depth, + _budget=_budget, + fingerprint_keys=fingerprint_keys, + ) + shape: dict[str, Any] = {"type": "object", "field_count": len(value)} + if fields: + shape["fields"] = fields + if truncated: + shape[_TRUNCATED_FIELD] = _truncated_object_shape() + return shape + if isinstance(value, list | tuple): + return {"type": "array", "length": len(value)} + if value is None: + return {"type": "null"} + return {"type": type(value).__name__} + + +def _sanitize_input_summary(summary: dict[str, Any]) -> dict[str, Any]: + sanitized = _sanitize_summary_value("input_summary", summary, _budget=_shape_budget()) + return sanitized if isinstance(sanitized, dict) else {} + + +def _sanitize_summary_value( + key: str, + value: Any, + *, + _depth: int = 0, + _budget: dict[str, int], +) -> Any: + if _budget["nodes"] <= 0: + return _truncated_object_shape() if isinstance(value, dict) else _summary_scalar_shape(value) + _budget["nodes"] -= 1 + if isinstance(value, dict): + if _depth >= _REDACTED_MAX_DEPTH: + return _truncated_object_shape() + sanitized: dict[str, Any] = {} + for index, (child_key, child_value) in enumerate(value.items()): + if index >= _REDACTED_MAX_FIELDS or _budget["nodes"] <= 0: + sanitized[_TRUNCATED_FIELD] = _truncated_object_shape() + break + safe_child_key = _safe_summary_child_key(key, child_key) + sanitized[safe_child_key] = _sanitize_summary_value( + str(child_key), + child_value, + _depth=_depth + 1, + _budget=_budget, + ) + return sanitized + if isinstance(value, list | tuple): + sanitized_items: list[Any] = [] + for index, item in enumerate(value): + if index >= _REDACTED_MAX_FIELDS or _budget["nodes"] <= 0: + sanitized_items.append(_truncated_object_shape()) + break + sanitized_items.append( + _sanitize_summary_value( + key, + item, + _depth=_depth + 1, + _budget=_budget, + ) + ) + return sanitized_items + if isinstance(value, str): + return _safe_summary_text(key, value) + if value is None or isinstance(value, bool | int): + return value + if isinstance(value, float): + return value if value == value and value not in {float("inf"), float("-inf")} else {"type": "float"} + return {"type": type(value).__name__} + + +def _safe_summary_text(key: str, value: str) -> str: + if _is_fingerprint(value): + return value + if key.endswith("_fields") or key.endswith("_fingerprint"): + return fingerprint_text(value) + if key == "pathname_shape" and _SAFE_PATHNAME_SHAPE.fullmatch(value): + return value + if key in {"tool_name", "product", "action", "region", "style", "method", "type"} and _SAFE_ID.fullmatch(value): + return value + return fingerprint_text(value) + + +def _safe_summary_child_key(parent_key: str, child_key: Any) -> str: + text = str(child_key) + if parent_key.endswith("_field_shapes"): + return text if _is_fingerprint(text) else fingerprint_text(text) + return _safe_field_name(child_key) + + +def _display_field_name(key: Any) -> str: + text = str(key) + if _is_fingerprint(text): + return text + if _SAFE_ID.fullmatch(text) and not _is_secret_key(text): + return text + return fingerprint_text(text) + + +def _safe_field_name(key: Any) -> str: + text = str(key) + if _is_fingerprint(text): + return text + if text in _SAFE_SHAPE_FIELD_NAMES and not _is_secret_key(text): + return text + return fingerprint_text(text) + + +def _safe_reason_token(value: str | None) -> str | None: + if value is None: + return None + if _SAFE_ID.fullmatch(value): + return value + return fingerprint_text(value) + + +def _event_name(record: PermissionAuditRecord) -> str: + if record.source in {"repl_prompt", "acp_prompt"}: + if record.decision == "allow": + return Events.TOOL_USE_GRANTED_IN_PROMPT + return Events.TOOL_USE_REJECTED_IN_PROMPT + if record.decision == "allow": + return Events.TOOL_PERMISSION_GRANTED + return Events.TOOL_PERMISSION_REJECTED + + +def _log_path() -> Path: + log_dir = ensure_private_dir(get_config_dir() / "logs") + return log_dir / "permission-audit.jsonl" + + +def _ensure_audit_log_files_private(path: Path, *, max_files: int) -> None: + ensure_private_file(path) + _assert_private_audit_file(path) + for index in range(1, max_files + 1): + rotated = path.with_name(f"{path.name}.{index}") + ensure_private_file(rotated) + _assert_private_audit_file(rotated) + + +def _ensure_existing_audit_log_files_private(path: Path, *, max_files: int) -> None: + if path.exists(): + ensure_private_file(path) + _assert_private_audit_file(path) + for index in range(1, max_files + 1): + rotated = path.with_name(f"{path.name}.{index}") + if rotated.exists(): + ensure_private_file(rotated) + _assert_private_audit_file(rotated) + + +def _assert_private_audit_file(path: Path) -> None: + if os.name == "nt" or not path.exists(): + return + mode = stat.S_IMODE(path.stat().st_mode) + if mode & 0o077: + raise PermissionError(f"Permission audit log file is not private: {path}") diff --git a/src/iac_code/services/permissions/loader.py b/src/iac_code/services/permissions/loader.py index c0194afc..8521aa79 100644 --- a/src/iac_code/services/permissions/loader.py +++ b/src/iac_code/services/permissions/loader.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from pathlib import Path from typing import Any @@ -9,7 +10,12 @@ from loguru import logger from iac_code.i18n import _ -from iac_code.types.permissions import PermissionMode, ToolPermissionContext +from iac_code.types.permissions import ( + MAX_PERMISSION_AUDIT_FILES, + PermissionAuditSettings, + PermissionMode, + ToolPermissionContext, +) def _get_global_settings_path() -> Path: @@ -26,6 +32,7 @@ def _empty_permissions_dict() -> dict[str, Any]: "ask": [], "mode": None, "additional_directories": [], + "audit": {}, } @@ -41,6 +48,33 @@ def _coerce_str_list(value: Any) -> list[str]: return out +def _coerce_positive_int(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and value > 0: + return value + if isinstance(value, str): + try: + parsed = int(value) + except ValueError: + return None + if parsed > 0: + return parsed + return None + + +def _coerce_bool(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "yes", "1", "on"}: + return True + if normalized in {"false", "no", "0", "off"}: + return False + return None + + def parse_cli_permission_mode(value: str) -> PermissionMode: """Parse a CLI permission mode, raising on invalid explicit input.""" try: @@ -53,7 +87,7 @@ def parse_cli_permission_mode(value: str) -> PermissionMode: def load_settings_permissions(path: Path, source: str) -> dict[str, Any]: """Load the permissions section from a single settings.yml file. - Returns: {"allow": [...], "deny": [...], "ask": [...], "mode": str|None, "additional_directories": [...]} + Returns permission rules, mode, additional directories, and audit settings loaded from this file. If file doesn't exist or has no permissions section, returns empty lists. """ _ = source @@ -76,6 +110,8 @@ def load_settings_permissions(path: Path, source: str) -> dict[str, Any]: mode = mode_raw.strip() else: mode = None + audit_raw = perms.get("audit") + audit = dict(audit_raw) if isinstance(audit_raw, dict) else {} return { "allow": _coerce_str_list(perms.get("allow")), @@ -83,6 +119,7 @@ def load_settings_permissions(path: Path, source: str) -> dict[str, Any]: "ask": _coerce_str_list(perms.get("ask")), "mode": mode, "additional_directories": _coerce_str_list(perms.get("additional_directories")), + "audit": audit, } @@ -111,6 +148,44 @@ def _apply_yaml_layer( logger.warning("Invalid permission mode '{}' in {}; valid: {}", data["mode"], source_key, valid) +def _apply_audit_layer(settings: PermissionAuditSettings, data: dict[str, Any]) -> None: + audit = data.get("audit") + if not isinstance(audit, dict): + return + + if "include_tool_input" in audit: + include_tool_input = _coerce_bool(audit["include_tool_input"]) + if include_tool_input is not None: + settings.include_tool_input = include_tool_input + if "max_file_bytes" in audit: + max_file_bytes = _coerce_positive_int(audit["max_file_bytes"]) + if max_file_bytes is not None: + settings.max_file_bytes = max_file_bytes + else: + logger.warning( + "Invalid permissions.audit.max_file_bytes value {}; expected positive integer", + audit["max_file_bytes"], + ) + if "max_files" in audit: + max_files = _coerce_positive_int(audit["max_files"]) + if max_files is not None: + if max_files > MAX_PERMISSION_AUDIT_FILES: + logger.warning( + "permissions.audit.max_files value {} exceeds maximum {}; using {}", + audit["max_files"], + MAX_PERMISSION_AUDIT_FILES, + MAX_PERMISSION_AUDIT_FILES, + ) + settings.max_files = MAX_PERMISSION_AUDIT_FILES + else: + settings.max_files = max_files + else: + logger.warning( + "Invalid permissions.audit.max_files value {}; expected positive integer", + audit["max_files"], + ) + + def load_permission_context( cwd: str, cli_allowed: list[str] | None = None, @@ -133,6 +208,7 @@ def load_permission_context( ask_rules: dict[str, list[str]] = {} additional_directories: list[str] = [] mode_holder: list[PermissionMode | None] = [None] + audit_settings = PermissionAuditSettings() layers: list[tuple[str, Path]] = [ ("user_settings", _get_global_settings_path()), @@ -151,6 +227,7 @@ def load_permission_context( additional_directories=additional_directories, mode_holder=mode_holder, ) + _apply_audit_layer(audit_settings, layer) if cli_allowed: allow_rules["cli_arg"] = list(cli_allowed) @@ -159,6 +236,12 @@ def load_permission_context( if cli_mode is not None: mode_holder[0] = parse_cli_permission_mode(cli_mode) + include_tool_input_env = os.getenv("IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT") + if include_tool_input_env is not None: + include_tool_input = _coerce_bool(include_tool_input_env) + if include_tool_input is not None: + audit_settings.include_tool_input = include_tool_input + resolved_mode = mode_holder[0] if mode_holder[0] is not None else PermissionMode.DEFAULT return ToolPermissionContext( @@ -169,4 +252,5 @@ def load_permission_context( ask_rules=ask_rules, additional_directories=additional_directories, trusted_read_directories=[], + audit_settings=audit_settings, ) diff --git a/src/iac_code/services/permissions/pipeline.py b/src/iac_code/services/permissions/pipeline.py index 0e82a66e..47e995cb 100644 --- a/src/iac_code/services/permissions/pipeline.py +++ b/src/iac_code/services/permissions/pipeline.py @@ -3,8 +3,15 @@ from __future__ import annotations from iac_code.i18n import _ +from iac_code.services.permissions.rule_scope import scope_for_rule_source from iac_code.tools.base import Tool -from iac_code.types.permissions import PermissionDecisionReason, PermissionMode, PermissionResult, ToolPermissionContext +from iac_code.types.permissions import ( + PermissionAuditMetadata, + PermissionDecisionReason, + PermissionMode, + PermissionResult, + ToolPermissionContext, +) _STICKY_ASK_REASONS = frozenset( { @@ -12,21 +19,56 @@ "path_constraint", "dangerous_readonly_argument", "complex_command", + "compound_cd", + "compound_cd_git", + "compound_limit", "parse_error", "too_complex", } ) -def _get_tool_rule(tool_name: str, rules_by_source: dict[str, list[str]]) -> str | None: +def _is_explicit_aliyun_write_allow(result: PermissionResult, tool: Tool) -> bool: + return ( + tool.name == "aliyun_api" + and result.behavior == "allow" + and result.audit is not None + and result.audit.is_read_only is not True + and result.audit.reason_type == "rule" + ) + + +def _get_tool_rule(tool_name: str, rules_by_source: dict[str, list[str]]) -> tuple[str, str] | None: """Check if there's a bare tool-name rule (e.g. 'write_file' without parens).""" for source, rules in rules_by_source.items(): for rule in rules: if rule == tool_name: - return source + return source, rule return None +def _audit( + *, + scope: str, + rule_source: str | None = None, + rule: str | None = None, + reason_type: str | None = None, + reason_detail: str | None = None, + is_read_only: bool | None = None, + inherit: PermissionAuditMetadata | None = None, +) -> PermissionAuditMetadata: + return PermissionAuditMetadata( + scope=scope, + source="permission_pipeline", + rule_source=rule_source, + rule=rule, + reason_type=reason_type, + reason_detail=reason_detail, + is_read_only=inherit.is_read_only if inherit is not None and inherit.is_read_only is not None else is_read_only, + operation=dict(inherit.operation) if inherit is not None else {}, + ) + + def _is_safety_check_ask(result: PermissionResult) -> bool: return result.behavior == "ask" and result.reason is not None and result.reason.type == "safety_check" @@ -35,16 +77,46 @@ def _is_sticky_ask(result: PermissionResult) -> bool: return result.behavior == "ask" and result.reason is not None and result.reason.type in _STICKY_ASK_REASONS +def _with_prompt_audit(tool: Tool, input: dict, result: PermissionResult) -> PermissionResult: + if result.audit is not None or result.behavior != "ask": + return result + reason_type = result.reason.type if result.reason is not None else "prompt_required" + return PermissionResult( + behavior=result.behavior, + message=result.message, + reason=result.reason, + suggestions=result.suggestions, + audit=_audit( + scope="once", + reason_type=reason_type, + reason_detail=reason_type, + is_read_only=tool.is_read_only(input), + ), + ) + + async def check_tool_permission( tool: Tool, input: dict, context: ToolPermissionContext, ) -> PermissionResult: """Apply tool-level rules, tool-internal checks, mode, and post-processing.""" - tool_level_ask = _get_tool_rule(tool.name, context.ask_rules) is not None + ask_rule = _get_tool_rule(tool.name, context.ask_rules) - if _get_tool_rule(tool.name, context.deny_rules) is not None: - return PermissionResult(behavior="deny") + deny_rule = _get_tool_rule(tool.name, context.deny_rules) + if deny_rule is not None: + source, rule = deny_rule + return PermissionResult( + behavior="deny", + audit=_audit( + scope=scope_for_rule_source(source), + rule_source=source, + rule=rule, + reason_type="rule", + reason_detail="matched deny rule: {}".format(rule), + is_read_only=tool.is_read_only(input), + ), + ) result = await tool.check_permissions(input, context) @@ -52,40 +124,107 @@ async def check_tool_permission( return result if _is_safety_check_ask(result): - return result + return _with_prompt_audit(tool, input, result) - if result.behavior == "ask" and tool_level_ask: - return result + if result.behavior == "ask" and ask_rule is not None: + source, rule = ask_rule + detail = _("matched ask rule(s): {}").format(rule) + return PermissionResult( + behavior="ask", + message=result.message, + reason=result.reason, + suggestions=result.suggestions, + audit=_audit( + scope=scope_for_rule_source(source), + rule_source=source, + rule=rule, + reason_type="rule", + reason_detail=detail, + is_read_only=tool.is_read_only(input), + inherit=result.audit, + ), + ) - if result.behavior == "allow" and tool_level_ask: + if result.behavior == "allow" and ask_rule is not None: + source, rule = ask_rule detail = _("matched ask rule(s): {}").format(tool.name) return PermissionResult( behavior="ask", message=detail, reason=PermissionDecisionReason(type="rule", detail=detail), + audit=_audit( + scope=scope_for_rule_source(source), + rule_source=source, + rule=rule, + reason_type="rule", + reason_detail=_("matched ask rule(s): {}").format(rule), + is_read_only=tool.is_read_only(input), + inherit=result.audit, + ), ) - if context.mode == PermissionMode.BYPASS_PERMISSIONS and not _is_safety_check_ask(result): - return PermissionResult(behavior="allow") + if ( + context.mode == PermissionMode.BYPASS_PERMISSIONS + and not _is_safety_check_ask(result) + and not _is_explicit_aliyun_write_allow(result, tool) + ): + return PermissionResult( + behavior="allow", + audit=_audit( + scope="mode", + rule_source="mode", + reason_type="bypass_permissions", + reason_detail="bypass_permissions mode", + is_read_only=tool.is_read_only(input), + inherit=result.audit, + ), + ) if _is_sticky_ask(result): - return result + return _with_prompt_audit(tool, input, result) if ( - _get_tool_rule(tool.name, context.allow_rules) is not None + (allow_rule := _get_tool_rule(tool.name, context.allow_rules)) is not None and result.behavior in ("passthrough", "ask") and tool.supports_blanket_allow ): - return PermissionResult(behavior="allow") + source, rule = allow_rule + return PermissionResult( + behavior="allow", + audit=_audit( + scope=scope_for_rule_source(source), + rule_source=source, + rule=rule, + reason_type="rule", + reason_detail="matched allow rule: {}".format(rule), + is_read_only=tool.is_read_only(input), + ), + ) if result.behavior == "passthrough": result = PermissionResult( behavior="ask", message=_("Allow {}?").format(tool.user_facing_name(input)), suggestions=result.suggestions, + audit=_audit( + scope="once", + reason_type="prompt_required", + reason_detail="prompt_required", + is_read_only=tool.is_read_only(input), + ), ) if context.mode == PermissionMode.DONT_ASK and result.behavior == "ask": - return PermissionResult(behavior="deny") + return PermissionResult( + behavior="deny", + audit=_audit( + scope="mode", + rule_source="mode", + reason_type="dont_ask", + reason_detail="dont_ask converted ask to deny", + is_read_only=tool.is_read_only(input), + inherit=result.audit, + ), + ) return result diff --git a/src/iac_code/services/permissions/rule_scope.py b/src/iac_code/services/permissions/rule_scope.py new file mode 100644 index 00000000..21cf0120 --- /dev/null +++ b/src/iac_code/services/permissions/rule_scope.py @@ -0,0 +1,13 @@ +"""Shared permission rule source to audit scope mapping.""" + +from __future__ import annotations + + +def scope_for_rule_source(source: str) -> str: + if source == "cli_arg": + return "cli_rule" + if source == "session": + return "session_rule" + if source.endswith("_settings"): + return "settings_rule" + return source diff --git a/src/iac_code/services/telemetry/names.py b/src/iac_code/services/telemetry/names.py index f0336d88..30543619 100644 --- a/src/iac_code/services/telemetry/names.py +++ b/src/iac_code/services/telemetry/names.py @@ -139,11 +139,13 @@ class Events: API_REQUEST_RETRIED = "iac.api.request.retried" MODEL_FALLBACK_TRIGGERED = "iac.model.fallback.triggered" - # --- Tool (4) --- + # --- Tool (6) --- TOOL_USE_SUCCEEDED = "iac.tool.use.succeeded" TOOL_USE_FAILED = "iac.tool.use.failed" TOOL_USE_GRANTED_IN_PROMPT = "iac.tool.use.granted_in_prompt" TOOL_USE_REJECTED_IN_PROMPT = "iac.tool.use.rejected_in_prompt" + TOOL_PERMISSION_GRANTED = "iac.tool.permission.granted" + TOOL_PERMISSION_REJECTED = "iac.tool.permission.rejected" # --- IaC core (9) --- TEMPLATE_GENERATED = "iac.template.generated" diff --git a/src/iac_code/tools/bash/mode_validation.py b/src/iac_code/tools/bash/mode_validation.py index 501155ec..384fa6ba 100644 --- a/src/iac_code/tools/bash/mode_validation.py +++ b/src/iac_code/tools/bash/mode_validation.py @@ -4,7 +4,12 @@ from typing import TYPE_CHECKING -from iac_code.types.permissions import PermissionMode, PermissionResult +from iac_code.types.permissions import ( + PermissionAuditMetadata, + PermissionDecisionReason, + PermissionMode, + PermissionResult, +) if TYPE_CHECKING: from iac_code.tools.bash.command_parser import SimpleCommand @@ -23,5 +28,18 @@ def check_permission_mode(cmd: SimpleCommand, mode: PermissionMode) -> Permissio return PermissionResult(behavior="passthrough") base = cmd.argv[0] if cmd.argv else "" if is_filesystem_command(base): - return PermissionResult(behavior="allow") + detail = "accept_edits mode" + return PermissionResult( + behavior="allow", + reason=PermissionDecisionReason(type="accept_edits", detail=detail), + audit=PermissionAuditMetadata( + scope="mode", + source="permission_pipeline", + rule_source="mode", + reason_type="accept_edits", + reason_detail=detail, + is_read_only=False, + operation={"is_read_only": False}, + ), + ) return PermissionResult(behavior="passthrough") diff --git a/src/iac_code/tools/bash/permissions.py b/src/iac_code/tools/bash/permissions.py index c1fbc64b..347ee09e 100644 --- a/src/iac_code/tools/bash/permissions.py +++ b/src/iac_code/tools/bash/permissions.py @@ -5,6 +5,7 @@ import os from iac_code.i18n import _ +from iac_code.services.permissions.rule_scope import scope_for_rule_source from iac_code.tools.bash.argv_safety import dangerous_readonly_argument from iac_code.tools.bash.command_parser import ParseResult, SimpleCommand, parse_command from iac_code.tools.bash.mode_validation import check_permission_mode @@ -13,6 +14,7 @@ from iac_code.tools.bash.rule_matching import find_matching_rules, normalize_command from iac_code.tools.bash.safety_checks import check_command_safety, check_safety from iac_code.types.permissions import ( + PermissionAuditMetadata, PermissionDecisionReason, PermissionResult, PermissionRuleValue, @@ -24,6 +26,51 @@ _BEHAVIOR_ORDER = {"deny": 0, "ask": 1, "passthrough": 2, "allow": 3} +def _matching_rules_with_sources( + command: str, + rules_by_source: dict[str, list[str]], + behavior: str, +) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for source, rules in rules_by_source.items(): + if behavior == "allow": + matched = find_matching_rules(command, rules, [], [])["allow"] + elif behavior == "deny": + matched = find_matching_rules(command, [], rules, [])["deny"] + else: + matched = find_matching_rules(command, [], [], rules)["ask"] + out.extend((source, rule) for rule in matched) + return out + + +def _rule_audit( + matches: list[tuple[str, str]], + *, + reason_detail: str, + is_read_only: bool, +) -> PermissionAuditMetadata | None: + if not matches: + return None + source, rule = matches[0] + return PermissionAuditMetadata( + scope=scope_for_rule_source(source), + source="permission_pipeline", + rule_source=source, + rule=rule, + reason_type="rule", + reason_detail=reason_detail, + is_read_only=is_read_only, + operation={"is_read_only": is_read_only}, + ) + + +def _command_text_is_readonly(command: str) -> bool: + parsed = parse_command(command) + if parsed.kind != "simple" or not parsed.commands: + return False + return all(is_command_readonly(cmd) for cmd in parsed.commands) + + def _collect_all_rules(rules_by_source: dict[str, list[str]]) -> list[str]: out: list[str] = [] for _source, rules in rules_by_source.items(): @@ -70,11 +117,18 @@ def _merge_results(results: list[PermissionResult]) -> PermissionResult: if not results: return PermissionResult(behavior="passthrough") _, best = min(enumerate(results), key=lambda ie: (_BEHAVIOR_ORDER[ie[1].behavior], ie[0])) + audit = best.audit + if audit is None and best.behavior == "allow": + audit = next( + (result.audit for result in results if result.behavior == "allow" and result.audit is not None), + None, + ) return PermissionResult( behavior=best.behavior, message=best.message, reason=best.reason, suggestions=best.suggestions, + audit=audit, ) @@ -99,6 +153,7 @@ def _with_suggestions_if_needed( message=result.message, reason=result.reason, suggestions=sug, + audit=result.audit, ) @@ -128,17 +183,19 @@ def bash_tool_check_permission( if not cmd.argv: return PermissionResult(behavior="passthrough") - allow_flat = _collect_all_rules(context.allow_rules) - deny_flat = _collect_all_rules(context.deny_rules) - ask_flat = _collect_all_rules(context.ask_rules) - - matched = find_matching_rules(cmd.text, allow_flat, deny_flat, ask_flat) + matched_by_source = { + "allow": _matching_rules_with_sources(cmd.text, context.allow_rules, "allow"), + "deny": _matching_rules_with_sources(cmd.text, context.deny_rules, "deny"), + "ask": _matching_rules_with_sources(cmd.text, context.ask_rules, "ask"), + } + matched = {behavior: [rule for _source, rule in matches] for behavior, matches in matched_by_source.items()} if matched["deny"]: detail = _("matched deny rule(s): {}").format(", ".join(matched["deny"])) return PermissionResult( behavior="deny", message=detail, reason=PermissionDecisionReason(type="rule", detail=detail), + audit=_rule_audit(matched_by_source["deny"], reason_detail=detail, is_read_only=is_command_readonly(cmd)), ) if matched["ask"]: detail = _("matched ask rule(s): {}").format(", ".join(matched["ask"])) @@ -146,6 +203,7 @@ def bash_tool_check_permission( behavior="ask", message=detail, reason=PermissionDecisionReason(type="rule", detail=detail), + audit=_rule_audit(matched_by_source["ask"], reason_detail=detail, is_read_only=is_command_readonly(cmd)), ) path_res = check_path_constraints(cmd, context.cwd, context.additional_directories) @@ -185,6 +243,7 @@ def bash_tool_check_permission( behavior="allow", message=detail, reason=PermissionDecisionReason(type="rule", detail=detail), + audit=_rule_audit(matched_by_source["allow"], reason_detail=detail, is_read_only=is_command_readonly(cmd)), ) mode_res = check_permission_mode(cmd, context.mode) @@ -214,16 +273,21 @@ async def bash_tool_has_permission(command: str, context: ToolPermissionContext) ) allow_flat = _collect_all_rules(context.allow_rules) - deny_flat = _collect_all_rules(context.deny_rules) ask_flat = _collect_all_rules(context.ask_rules) - full_matches = find_matching_rules(command, allow_flat, deny_flat, ask_flat) + full_deny_matches = _matching_rules_with_sources(command, context.deny_rules, "deny") + full_matches = { + "allow": find_matching_rules(command, allow_flat, [], [])["allow"], + "deny": [rule for _source, rule in full_deny_matches], + "ask": find_matching_rules(command, [], [], ask_flat)["ask"], + } if full_matches["deny"]: detail = _("matched deny rule(s) on full command: {}").format(", ".join(full_matches["deny"])) return PermissionResult( behavior="deny", message=detail, reason=PermissionDecisionReason(type="rule", detail=detail), + audit=_rule_audit(full_deny_matches, reason_detail=detail, is_read_only=_command_text_is_readonly(command)), ) parsed: ParseResult = parse_command(command) diff --git a/src/iac_code/tools/cloud/aliyun/aliyun_api.py b/src/iac_code/tools/cloud/aliyun/aliyun_api.py index 6fd40bb9..a5ea405b 100644 --- a/src/iac_code/tools/cloud/aliyun/aliyun_api.py +++ b/src/iac_code/tools/cloud/aliyun/aliyun_api.py @@ -2,8 +2,10 @@ from __future__ import annotations +import fnmatch import json import logging +import re import time from pathlib import Path from typing import Any @@ -15,6 +17,8 @@ from iac_code.i18n import _ from iac_code.services.cloud_credentials import CloudCredentials +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.services.permissions.rule_scope import scope_for_rule_source from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials from iac_code.services.providers.aliyun_oauth import AliyunOAuthError from iac_code.services.telemetry import add_metric, log_event @@ -24,6 +28,13 @@ from iac_code.tools.cloud.aliyun.template_source import reject_template_body_param from iac_code.tools.cloud.aliyun.user_agent import build_user_agent from iac_code.tools.cloud.base_api import BaseCloudApi +from iac_code.types.permissions import ( + PermissionAuditMetadata, + PermissionDecisionReason, + PermissionResult, + PermissionRuleValue, + ToolPermissionContext, +) from iac_code.types.stream_events import ResourceObservedEvent logger = logging.getLogger(__name__) @@ -62,6 +73,7 @@ def _load_endpoints() -> dict[str, Any]: _VERSION_MAP_LOWER: dict[str, str] = {k.lower(): v for k, v in VERSION_MAP.items()} _PRODUCT_CANONICAL: dict[str, str] = {k.lower(): k for k in VERSION_MAP} _ENDPOINTS_CANONICAL: dict[str, str] = {k.lower(): k for k in _ENDPOINTS} +_SAFE_API_VERSION = re.compile(r"^\d{4}-\d{2}-\d{2}$") # Cache for Location service discovered endpoints _endpoint_cache: dict[tuple[str, str], str | None] = {} @@ -150,6 +162,127 @@ def _string_value(value: Any) -> str | None: return value if isinstance(value, str) and value else None +_SAFE_RULE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_SAFE_WILDCARD_SEGMENT = re.compile(r"^[A-Za-z0-9_*-]{1,128}$") +_RULE_SOURCE_ORDER = { + "session": 5, + "cli_arg": 4, + "local_settings": 3, + "project_settings": 2, + "user_settings": 1, +} + + +def _canonical_product(product: str) -> str: + return _PRODUCT_CANONICAL.get(product.lower(), product) + + +def _safe_exact_identifier(value: str) -> bool: + return bool(_SAFE_RULE_ID.fullmatch(value)) + + +def _add_telemetry_identifier( + metadata: dict[str, Any], + key: str, + fingerprint_key: str, + value: str, + *, + uppercase: bool = False, +) -> None: + if not value: + return + if _safe_exact_identifier(value): + metadata[key] = value.upper() if uppercase else value + else: + metadata[fingerprint_key] = fingerprint_text(value) + + +def _aliyun_api_telemetry_identifiers(product: str, action: str, region: str) -> dict[str, Any]: + metadata: dict[str, Any] = {} + _add_telemetry_identifier( + metadata, + "api_service", + "api_service_fingerprint", + product, + uppercase=True, + ) + _add_telemetry_identifier(metadata, "api_name", "api_name_fingerprint", action) + _add_telemetry_identifier(metadata, "region", "region_fingerprint", region) + return metadata + + +def _aliyun_api_metric_attrs(product: str, outcome: str) -> dict[str, str]: + api_service = product.upper() if product and _safe_exact_identifier(product) else "unsafe" + return {"api_service": api_service, "outcome": outcome} + + +def _aliyun_api_version_telemetry(version: str) -> dict[str, str]: + if _SAFE_API_VERSION.fullmatch(version): + return {"api_version": version} + return {"api_version_fingerprint": fingerprint_text(version)} + + +def _scrub_unsafe_identifier_text(value: str | None, *identifiers: str) -> str | None: + if value is None: + return None + sanitized = sanitize_error_message(value) + if sanitized is None: + return None + for identifier in identifiers: + if not identifier or _safe_exact_identifier(identifier): + continue + sanitized = re.sub(re.escape(identifier), fingerprint_text(identifier), sanitized, flags=re.IGNORECASE) + return sanitized + + +def _parse_aliyun_rule(rule: str) -> tuple[str, str] | None: + prefix = "aliyun_api(" + if not rule.startswith(prefix) or not rule.endswith(")"): + return None + inner = rule[len(prefix) : -1] + if inner.count(":") != 1: + return None + product_pattern, action_pattern = inner.split(":", 1) + if not (_SAFE_WILDCARD_SEGMENT.fullmatch(product_pattern) and _SAFE_WILDCARD_SEGMENT.fullmatch(action_pattern)): + return None + return product_pattern, action_pattern + + +def _literal_count(pattern: str) -> int: + return len(pattern.replace("*", "")) + + +def _side_specificity(pattern: str, value: str) -> tuple[int, int]: + if pattern.lower() == value.lower(): + return (3, len(pattern)) + if pattern == "*": + return (1, 0) + return (2, _literal_count(pattern)) + + +def _safe_operation_identifiers(input: dict) -> tuple[str, str] | None: + product = _string_value(input.get("product")) + action = _string_value(input.get("action")) + if product is None or action is None: + return None + canonical_product = _canonical_product(product) + if not (_safe_exact_identifier(canonical_product) and _safe_exact_identifier(action)): + return None + return canonical_product, action + + +def _is_roa_style(input: dict) -> bool: + style = _string_value(input.get("style")) + return style is not None and style.upper() == "ROA" + + +def _is_roa_read_only_request(input: dict) -> bool: + method = _string_value(input.get("method")) + if method is None or method.upper() != "GET": + return False + return "body" not in input or input.get("body") is None + + class AliyunApi(BaseCloudApi): """Generic Alibaba Cloud API tool. @@ -177,6 +310,10 @@ def description(self) -> str: def user_facing_name(self, input: dict | None = None) -> str: return _("Aliyun API") + @property + def supports_blanket_allow(self) -> bool: + return False + def _get_default_region(self) -> str: credentials = CloudCredentials() cred = credentials.get_provider("aliyun") @@ -185,11 +322,171 @@ def _get_default_region(self) -> str: def is_read_only(self, input: dict | None = None) -> bool: if input is None: return False - product = input.get("product", "") - action = input.get("action", "") - if product == "ros" and action == "PreviewStack": + action = _string_value(input.get("action")) + if action is None or not _safe_exact_identifier(action): + return False + product = _string_value(input.get("product")) or "" + if product: + operation = _safe_operation_identifiers(input) + if operation is None: + return False + product, action = operation + if _is_roa_style(input) and not _is_roa_read_only_request(input): + return False + if product.lower() == "ros" and action.lower() == "previewstack": return True - return super().is_read_only(input) + return super().is_read_only({"action": action}) + + def _operation_metadata(self, input: dict) -> dict[str, object]: + product = _string_value(input.get("product")) + action = _string_value(input.get("action")) + region = _string_value(input.get("region_id")) + operation: dict[str, object] = {} + if product is not None: + canonical_product = _canonical_product(product) + if _safe_exact_identifier(canonical_product): + operation["product"] = canonical_product + else: + operation["product_fingerprint"] = fingerprint_text(product) + if action is not None and _safe_exact_identifier(action): + operation["action"] = action + elif action is not None: + operation["action_fingerprint"] = fingerprint_text(action) + if region is not None and _safe_exact_identifier(region): + operation["region"] = region + elif region is not None: + operation["region_fingerprint"] = fingerprint_text(region) + return operation + + def _audit( + self, + input: dict, + *, + scope: str, + rule_source: str | None = None, + rule: str | None = None, + reason: PermissionDecisionReason | None = None, + is_read_only: bool | None = None, + ) -> PermissionAuditMetadata: + return PermissionAuditMetadata( + scope=scope, + source="permission_pipeline", + rule_source=rule_source, + rule=rule, + reason_type=reason.type if reason else None, + reason_detail=reason.detail if reason else None, + is_read_only=is_read_only, + operation=self._operation_metadata(input), + ) + + def _supports_persistent_allow(self, input: dict, *, is_read_only: bool) -> bool: + return True + + def _suggestion(self, input: dict, *, is_read_only: bool = False) -> list[PermissionRuleValue] | None: + if not self._supports_persistent_allow(input, is_read_only=is_read_only): + return None + product = _string_value(input.get("product")) + action = _string_value(input.get("action")) + if product is None or action is None: + return None + product = _canonical_product(product) + if not (_safe_exact_identifier(product) and _safe_exact_identifier(action)): + return None + return [PermissionRuleValue(tool_name=self.name, rule_content="{}:{}".format(product, action))] + + def _matching_rule( + self, + input: dict, + rules_by_source: dict[str, list[str]], + *, + require_exact: bool = False, + ) -> tuple[str, str] | None: + operation = _safe_operation_identifiers(input) + if operation is None: + return None + canonical_product, action = operation + best: tuple[tuple[tuple[int, int], tuple[int, int], int, int], str, str] | None = None + + for source, rules in rules_by_source.items(): + for index, rule in enumerate(rules): + parsed = _parse_aliyun_rule(rule) + if parsed is None: + continue + product_pattern, action_pattern = parsed + if not fnmatch.fnmatchcase(canonical_product.lower(), product_pattern.lower()): + continue + if not fnmatch.fnmatchcase(action.lower(), action_pattern.lower()): + continue + if require_exact and ( + product_pattern.lower() != canonical_product.lower() or action_pattern.lower() != action.lower() + ): + continue + score = ( + _side_specificity(product_pattern, canonical_product), + _side_specificity(action_pattern, action), + _RULE_SOURCE_ORDER.get(source, 0), + index, + ) + rule_content = "{}:{}".format(product_pattern, action_pattern) + if best is None or score > best[0]: + best = (score, source, rule_content) + + if best is None: + return None + return best[1], best[2] + + async def check_permissions(self, input: dict, context=None) -> PermissionResult: + if not isinstance(context, ToolPermissionContext): + context = ToolPermissionContext(cwd=context.get("cwd", "") if isinstance(context, dict) else "") + + is_read_only = _safe_operation_identifiers(input) is not None and self.is_read_only(input) + supports_persistent_allow = self._supports_persistent_allow(input, is_read_only=is_read_only) + for behavior, rules_by_source in ( + ("deny", context.deny_rules), + ("ask", context.ask_rules), + ("allow", context.allow_rules), + ): + if behavior == "allow" and not supports_persistent_allow: + continue + match = self._matching_rule(input, rules_by_source, require_exact=behavior == "allow" and not is_read_only) + if match is None: + continue + rule_source, rule = match + detail = _("matched {behavior} rule: {rule}").format(behavior=behavior, rule=rule) + reason = PermissionDecisionReason(type="rule", detail=detail) + return PermissionResult( + behavior=behavior, + message=detail, + reason=reason, + audit=self._audit( + input, + scope=scope_for_rule_source(rule_source), + rule_source=rule_source, + rule=rule, + reason=reason, + is_read_only=is_read_only, + ), + ) + + if is_read_only: + reason = PermissionDecisionReason(type="read_only", detail="read-only Aliyun API action") + return PermissionResult( + behavior="allow", + reason=reason, + audit=self._audit(input, scope="read_only", reason=reason, is_read_only=True), + ) + + reason = PermissionDecisionReason( + type="untrusted_write", + detail="Aliyun API action may modify cloud resources", + ) + return PermissionResult( + behavior="ask", + message=_("Allow {}?").format(self.user_facing_name(input)), + reason=reason, + suggestions=self._suggestion(input, is_read_only=is_read_only), + audit=self._audit(input, scope="once", reason=reason, is_read_only=False), + ) @property def input_schema(self) -> dict[str, Any]: @@ -488,6 +785,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> # Prepare telemetry metadata api_service = product.upper() + telemetry_identifiers = _aliyun_api_telemetry_identifiers(product, action, region) started = time.monotonic() http_status: int | None = None error_code: str | None = None @@ -510,16 +808,14 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> log_event( Events.ALIYUN_API_CALLED, { - "api_service": api_service, - "api_name": action, - "api_version": version, - "region": region, + **telemetry_identifiers, + **_aliyun_api_version_telemetry(version), "outcome": outcome, "duration_ms": duration_ms, "http_status": http_status, }, ) - add_metric(Metrics.ALIYUN_API_CALLED_COUNT, 1, {"api_service": api_service, "outcome": outcome}) + add_metric(Metrics.ALIYUN_API_CALLED_COUNT, 1, _aliyun_api_metric_attrs(product, outcome)) add_metric(Metrics.ALIYUN_API_CALLED_DURATION, duration_ms) # Special case: ROS ValidateTemplate @@ -557,18 +853,16 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> log_event( Events.ALIYUN_API_CALLED, { - "api_service": api_service, - "api_name": action, - "api_version": version, - "region": region, + **telemetry_identifiers, + **_aliyun_api_version_telemetry(version), "outcome": outcome, "duration_ms": duration_ms, "http_status": http_status, - "error_code": error_code, - "error_message": sanitize_error_message(error_message), + "error_code": _scrub_unsafe_identifier_text(error_code, product, action, region, version), + "error_message": _scrub_unsafe_identifier_text(error_message, product, action, region, version), }, ) - add_metric(Metrics.ALIYUN_API_CALLED_COUNT, 1, {"api_service": api_service, "outcome": outcome}) + add_metric(Metrics.ALIYUN_API_CALLED_COUNT, 1, _aliyun_api_metric_attrs(product, outcome)) add_metric(Metrics.ALIYUN_API_CALLED_DURATION, duration_ms) return ToolResult.error(self._clean_error_message(error_str)) diff --git a/src/iac_code/tools/cloud/base_api.py b/src/iac_code/tools/cloud/base_api.py index b02de416..06622023 100644 --- a/src/iac_code/tools/cloud/base_api.py +++ b/src/iac_code/tools/cloud/base_api.py @@ -83,6 +83,8 @@ def is_read_only(self, input: dict | None = None) -> bool: if input is None: return False action = input.get("action", "") + if not isinstance(action, str): + return False return action.startswith(("Get", "List", "Describe", "Query", "Validate")) def is_concurrency_safe(self, tool_input: dict[str, Any]) -> bool: diff --git a/src/iac_code/types/permissions.py b/src/iac_code/types/permissions.py index b0eb45ae..b689ce35 100644 --- a/src/iac_code/types/permissions.py +++ b/src/iac_code/types/permissions.py @@ -6,6 +6,8 @@ from enum import Enum from typing import Literal +MAX_PERMISSION_AUDIT_FILES = 100 + class PermissionMode(str, Enum): """Permission mode.""" @@ -51,6 +53,29 @@ class PermissionDecisionReason: detail: str +@dataclass +class PermissionAuditSettings: + """Resolved permission audit settings.""" + + include_tool_input: bool = False + max_file_bytes: int = 10 * 1024 * 1024 + max_files: int = 5 + + +@dataclass +class PermissionAuditMetadata: + """Structured metadata used by the permission audit service.""" + + scope: str + source: str + rule_source: str | None = None + rule: str | None = None + reason_type: str | None = None + reason_detail: str | None = None + is_read_only: bool | None = None + operation: dict[str, object] = field(default_factory=dict) + + @dataclass class PermissionResult: """Permission check result.""" @@ -59,6 +84,7 @@ class PermissionResult: message: str = "" reason: PermissionDecisionReason | None = None suggestions: list[PermissionRuleValue] | None = None + audit: PermissionAuditMetadata | None = None @dataclass @@ -72,6 +98,7 @@ class ToolPermissionContext: ask_rules: dict[str, list[str]] = field(default_factory=dict) additional_directories: list[str] = field(default_factory=list) trusted_read_directories: list[str] = field(default_factory=list) + audit_settings: PermissionAuditSettings = field(default_factory=PermissionAuditSettings) PermissionDecision = Literal["always_allow", "always_deny"] diff --git a/src/iac_code/types/stream_events.py b/src/iac_code/types/stream_events.py index 46aa9299..27762746 100644 --- a/src/iac_code/types/stream_events.py +++ b/src/iac_code/types/stream_events.py @@ -131,6 +131,7 @@ class PermissionRequestEvent: tool_use_id: str response_future: asyncio.Future[bool] | None = field(default=None) permission_result: Any | None = field(default=None) + audit_context: Any | None = field(default=None, repr=False, compare=False) type: Literal["permission_request"] = "permission_request" diff --git a/src/iac_code/ui/renderer.py b/src/iac_code/ui/renderer.py index d7fdb2fe..1cc759a1 100644 --- a/src/iac_code/ui/renderer.py +++ b/src/iac_code/ui/renderer.py @@ -12,6 +12,7 @@ import asyncio import contextlib import copy +import json import os import re import sys @@ -19,7 +20,7 @@ import time from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable +from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, Literal if sys.platform != "win32": import termios @@ -38,8 +39,12 @@ from rich.text import Text from iac_code.i18n import _ -from iac_code.services.telemetry import add_metric, log_event -from iac_code.services.telemetry.names import Events, Metrics +from iac_code.services.permissions.audit import ( + build_input_summary, + emit_permission_boundary_audit, + is_permission_audit_non_read_only, + should_fail_closed_permission_audit, +) from iac_code.tools.cloud.types import translate_status from iac_code.types.stream_events import ( AskUserQuestionEvent, @@ -83,6 +88,13 @@ def _display_tool_name(tool_name: str, candidate: str | None = None) -> str: return known_tool_display_name(tool_name) or candidate or tool_name +def _permission_detail_text(tool_name: str, tool_input: dict[str, Any], tool: Any | None) -> str | None: + if tool_name == "aliyun_api": + summary = json.dumps(build_input_summary(tool_name, tool_input), ensure_ascii=False, sort_keys=True) + return _("Input summary: {summary}").format(summary=summary) + return tool.render_tool_use_message(tool_input) if tool else None + + def _windows_msvcrt_reader_loop( msvcrt_module: Any, loop: asyncio.AbstractEventLoop, @@ -1397,29 +1409,6 @@ async def _rebuild_after_transcript(): # task cancellation during a pipeline interrupt; mirrors the # defensive pattern in agent_tool / acp/session. if event.response_future is not None and not event.response_future.done(): - if allowed: - log_event( - Events.TOOL_USE_GRANTED_IN_PROMPT, - { - "tool_name": event.tool_name, - "scope": "once", - }, - ) - else: - log_event( - Events.TOOL_USE_REJECTED_IN_PROMPT, - { - "tool_name": event.tool_name, - }, - ) - add_metric( - Metrics.TOOL_USE_COUNT, - 1, - { - "tool_name": event.tool_name, - "outcome": "denied", - }, - ) event.response_future.set_result(allowed) # ── User question request ───────────────────── @@ -1628,9 +1617,31 @@ async def prompt_permission(self, event: PermissionRequestEvent) -> bool: tool_name = event.tool_name cache = self._app_state_store.get_state().always_allow_rules if self._app_state_store is not None else None + is_non_read_only = is_permission_audit_non_read_only(event) + + def _suggestion_rule() -> str | None: + if not suggestions: + return None + return ", ".join(f"{suggestion.tool_name}({suggestion.rule_content})" for suggestion in suggestions) # Short-circuit on cached sticky decisions — no prompt, no input read. cached = lookup_permission(cache, tool_name) + if cached == "always_allow" and tool_name == "aliyun_api" and is_non_read_only: + if cache is not None: + cache.pop(tool_name, None) + cached = None + if cached in ("always_allow", "always_deny"): + audit_ok = emit_permission_boundary_audit( + event, + decision="allow" if cached == "always_allow" else "deny", + scope="tool_cache", + source="repl_tool_cache", + rule_source="tool_cache", + reason_type="tool_cache", + reason_detail=cached, + ) + if cached == "always_allow" and not audit_ok and should_fail_closed_permission_audit(event, "allow"): + return False if cached == "always_allow": return True if cached == "always_deny": @@ -1638,9 +1649,7 @@ async def prompt_permission(self, event: PermissionRequestEvent) -> bool: tool = self._tool_registry.get(tool_name) tool_display = _display_tool_name(tool_name, tool.user_facing_name(event.tool_input) if tool else tool_name) - detail = None - if tool: - detail = tool.render_tool_use_message(event.tool_input) + detail = _permission_detail_text(tool_name, event.tool_input, tool) # Tool-use header. line = Text() @@ -1701,6 +1710,27 @@ async def prompt_permission(self, event: PermissionRequestEvent) -> bool: result = await loop.run_in_executor(None, select.run) if result is None: + result = "reject_once" + + prompt_audit: dict[str, tuple[Literal["allow", "deny"], str]] = { + "allow_once": ("allow", "once"), + "reject_once": ("deny", "once"), + "always_allow": ("allow", "tool_cache"), + "always_deny": ("deny", "tool_cache"), + "always_allow_rule": ("allow", "session_rule"), + "always_deny_rule": ("deny", "session_rule"), + } + audit_decision, audit_scope = prompt_audit.get(result, ("deny", "once")) + audit_ok = emit_permission_boundary_audit( + event, + decision=audit_decision, + scope=audit_scope, + source="repl_prompt", + reason_type="prompt_selection", + reason_detail=result, + rule=_suggestion_rule() if audit_scope == "session_rule" else None, + ) + if audit_decision == "allow" and not audit_ok and should_fail_closed_permission_audit(event, "allow"): return False if result == "allow_once": diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index 79c7a5ac..5e87296d 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -42,6 +42,14 @@ from iac_code.memory.recall import MemoryRecallService from iac_code.providers.manager import ProviderManager from iac_code.providers.registry import PROVIDER_REGISTRY +from iac_code.services.permissions.audit import ( + PermissionAuditRecord, + build_input_summary, + emit_permission_audit, + is_routine_read_only_allow, + permission_audit_operation, + redacted_tool_input_for_settings, +) from iac_code.services.session_index import SessionIndex from iac_code.services.session_metadata import normalize_session_name from iac_code.services.session_resolver import ResolutionStatus, resolve_session_argument @@ -130,6 +138,12 @@ def _normalize_command_result(result: object) -> tuple[str, bool, bool]: return str(result), False, False +def _innermost_sub_pipeline_stream_event(event: SubPipelineStreamEvent) -> SubPipelineStreamEvent: + while isinstance(event.inner, SubPipelineStreamEvent): + event = event.inner + return event + + class InlineREPL: """Inline terminal REPL integrating all subsystems.""" @@ -1092,10 +1106,21 @@ async def _request_shell_escape_permission(self, tool, tool_input: dict) -> bool permission = await check_tool_permission(tool, tool_input, permission_context) else: permission = await tool.check_permissions(tool_input, {"cwd": self._original_cwd}) + audit_settings = permission_context.audit_settings if permission_context is not None else None if permission.behavior == "allow": + audit_ok = self._audit_shell_escape_permission( + permission, + tool_input, + decision="allow", + settings=audit_settings, + ) + if not audit_ok: + self.renderer.print_system_message(_("Permission denied."), style="red") + return False return True if permission.behavior == "deny": + self._audit_shell_escape_permission(permission, tool_input, decision="deny", settings=audit_settings) self.renderer.print_system_message(permission.message or _("Permission denied."), style="red") return False @@ -1107,12 +1132,52 @@ async def _request_shell_escape_permission(self, tool, tool_input: dict) -> bool tool_input=tool_input, tool_use_id="shell-escape", permission_result=permission, + audit_context={ + "session_id": getattr(self, "_session_id", "") or "", + "settings": audit_settings, + "metadata": permission.audit, + }, ) ) if not allowed: self.renderer.print_system_message(_("Permission denied."), style="red") return allowed + def _audit_shell_escape_permission( + self, + permission, + tool_input: dict, + *, + decision: Literal["allow", "deny"], + settings=None, + ) -> bool: + audit = getattr(permission, "audit", None) + if audit is None: + return True + if is_routine_read_only_allow(decision, audit): + return True + return ( + emit_permission_audit( + PermissionAuditRecord( + session_id=getattr(self, "_session_id", "") or "", + tool_name="bash", + tool_use_id="shell-escape", + decision=decision, + scope=audit.scope, + source=audit.source, + rule_source=audit.rule_source, + rule=audit.rule, + reason_type=audit.reason_type, + reason_detail=audit.reason_detail, + operation=permission_audit_operation(audit), + input_summary=build_input_summary("bash", tool_input), + tool_input_redacted=redacted_tool_input_for_settings(tool_input, settings), + ), + settings=settings, + ) + is not False + ) + def _is_pipeline_safe_command(self, user_input: str) -> bool: """Commands always allowed mid-pipeline regardless of allow_user_escapes.command.""" first = user_input.split(None, 1)[0] if user_input else "" @@ -4076,8 +4141,9 @@ async def _prompt_child_permission(sub_id: str, inner: PermissionRequestEvent) - break elif isinstance(event, SubPipelineStreamEvent): - sub_id = event.sub_pipeline_id - inner = event.inner + stream_event = _innermost_sub_pipeline_stream_event(event) + sub_id = stream_event.sub_pipeline_id + inner = stream_event.inner if isinstance(inner, PermissionRequestEvent): await _prompt_child_permission(sub_id, inner) continue diff --git a/src/iac_code/utils/state_io.py b/src/iac_code/utils/state_io.py index bfcc271c..81d7d239 100644 --- a/src/iac_code/utils/state_io.py +++ b/src/iac_code/utils/state_io.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import os import shutil import sys @@ -15,6 +16,7 @@ from iac_code.utils.path_locks import PathLockRegistry +logger = logging.getLogger(__name__) _PATH_LOCKS = PathLockRegistry() @@ -198,3 +200,70 @@ def append_jsonl_locked( os.fsync(handle.fileno()) if durable and created: fsync_parent_dir(target) + + +def _rotate_jsonl_files(target: Path, *, max_file_bytes: int, max_files: int, pending_bytes: int = 0) -> None: + if max_file_bytes <= 0 or max_files <= 0: + return + if not target.exists(): + return + current_size = target.stat().st_size + if pending_bytes > 0: + if current_size + pending_bytes <= max_file_bytes: + return + elif current_size < max_file_bytes: + return + oldest = target.with_name(f"{target.name}.{max_files}") + if oldest.exists(): + oldest.unlink() + for index in range(max_files - 1, 0, -1): + current = target.with_name(f"{target.name}.{index}") + if current.exists(): + current.replace(target.with_name(f"{target.name}.{index + 1}")) + target.replace(target.with_name(f"{target.name}.1")) + + +def append_jsonl_rotating_locked( + path: str | Path, + records: Iterable[dict[str, Any]], + *, + max_file_bytes: int, + max_files: int, + durable: bool = False, + create_mode: int | None = None, +) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + lines = [ + json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n" for record in records + ] + if not lines: + return + with _path_lock(target): + with _cross_process_append_lock(target): + try: + pending_bytes = sum(len(line.encode("utf-8")) for line in lines) + _rotate_jsonl_files( + target, + max_file_bytes=max_file_bytes, + max_files=max_files, + pending_bytes=pending_bytes, + ) + except OSError as exc: + logger.warning("Could not rotate JSONL file %s; appending to active file: %s", target, exc) + created = not target.exists() + with _open_append_binary(target, create_mode=create_mode) as handle: + for line in lines: + handle.write(line.encode("utf-8")) + handle.flush() + if durable: + os.fsync(handle.fileno()) + if durable and created: + fsync_parent_dir(target) + + +def _open_append_binary(path: Path, *, create_mode: int | None = None): + if create_mode is None: + return path.open("ab") + fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, create_mode) + return os.fdopen(fd, "ab") diff --git a/tests/a2a/test_events.py b/tests/a2a/test_events.py index a8d1f7a0..8385d2bd 100644 --- a/tests/a2a/test_events.py +++ b/tests/a2a/test_events.py @@ -4,11 +4,13 @@ from iac_code.a2a.events import _ERROR_TEXT_MAX_CHARS, _METADATA_MAX_CHARS, _truncate, publish_stream_event from iac_code.a2a.exposure import A2AExposureType +from iac_code.services.permissions.audit import fingerprint_text from iac_code.types.stream_events import ( ErrorEvent, MCPProgressEvent, MessageEndEvent, PermissionRequestEvent, + SubPipelineStreamEvent, TextDeltaEvent, ThinkingDeltaEvent, ToolInputDeltaEvent, @@ -47,7 +49,7 @@ async def test_empty_text_delta_is_ignored() -> None: @pytest.mark.asyncio -async def test_permission_request_is_denied_by_default_and_truncated() -> None: +async def test_permission_request_is_denied_by_default_and_uses_shape_only_tool_input() -> None: queue = FakeEventQueue() future = pending_future() long_value = "x" * (_METADATA_MAX_CHARS + 100) @@ -60,7 +62,11 @@ async def test_permission_request_is_denied_by_default_and_truncated() -> None: assert future.result() is False dumped = dump(queue.events[0]) assert dumped["metadata"]["iac_code"]["permission"]["autoApproved"] is False - assert len(dumped["metadata"]["iac_code"]["permission"]["toolInput"]["cmd"]) == _METADATA_MAX_CHARS + assert dumped["metadata"]["iac_code"]["permission"]["toolInput"]["cmd"] == { + "type": "str", + "length": len(long_value), + "fingerprint": fingerprint_text(long_value), + } @pytest.mark.asyncio @@ -108,8 +114,97 @@ async def test_permission_request_tool_input_redacts_secret_values() -> None: assert "sk-live-secret" not in str(tool_input) assert "Authorization: Bearer" not in str(tool_input) assert "/Users/alice" not in str(tool_input) - assert "[REDACTED]" in str(tool_input) - assert "[PATH]" in str(tool_input) + assert tool_input["cmd"] == { + "type": "str", + "length": len(event.tool_input["cmd"]), + "fingerprint": fingerprint_text(event.tool_input["cmd"]), + } + + +@pytest.mark.asyncio +async def test_permission_request_tool_input_redacts_nested_secret_fields() -> None: + queue = FakeEventQueue() + event = PermissionRequestEvent( + tool_name="bash", + tool_input={ + "product": "ros", + "action": "CreateStack", + "customerEmail": "alice@example.com", + "params": { + "StackName": "demo", + "customer-prod-123": "tenant-id", + "AccessKeySecret": "secret-value", + "private_key": "private-secret", + "Signature": "signature-secret", + }, + }, + tool_use_id="tool-1", + ) + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=event) + + dumped = dump(queue.events[0]) + tool_input = dumped["metadata"]["iac_code"]["permission"]["toolInput"] + for forbidden in ( + "secret-value", + "private-secret", + "signature-secret", + "AccessKeySecret", + "private_key", + "Signature", + "customerEmail", + "customer-prod-123", + ): + assert forbidden not in str(tool_input) + assert tool_input[fingerprint_text("customerEmail")] == { + "type": "str", + "length": len("alice@example.com"), + "fingerprint": fingerprint_text("alice@example.com"), + } + assert tool_input["params"][fingerprint_text("StackName")] == { + "type": "str", + "length": 4, + "fingerprint": fingerprint_text("demo"), + } + assert tool_input["params"][fingerprint_text("customer-prod-123")] == { + "type": "str", + "length": len("tenant-id"), + "fingerprint": fingerprint_text("tenant-id"), + } + assert tool_input["params"][fingerprint_text("AccessKeySecret")] == {"redacted": True} + assert tool_input["params"][fingerprint_text("private_key")] == {"redacted": True} + assert tool_input["params"][fingerprint_text("Signature")] == {"redacted": True} + + +@pytest.mark.asyncio +async def test_aliyun_permission_metadata_uses_summary_for_sensitive_safe_fields() -> None: + queue = FakeEventQueue() + pem = "-----BEGIN PRIVATE KEY-----\nprivate-body\n-----END PRIVATE KEY-----" + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={ + "product": "ros", + "action": "CreateStack", + "params": {"TemplateBody": pem, "StackName": "demo"}, + }, + tool_use_id="tool-1", + ) + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=event) + + dumped = dump(queue.events[0]) + permission = dumped["metadata"]["iac_code"]["permission"] + rendered = str(permission) + assert "toolInput" not in permission + assert permission["inputSummary"]["tool_name"] == "aliyun_api" + assert permission["inputSummary"]["params_fields"] == sorted( + [fingerprint_text("StackName"), fingerprint_text("TemplateBody")] + ) + assert permission["inputSummary"]["params_field_count"] == 2 + assert "StackName" not in rendered + assert "TemplateBody" not in rendered + assert "private-body" not in rendered + assert "BEGIN PRIVATE KEY" not in rendered @pytest.mark.asyncio @@ -130,13 +225,18 @@ async def test_permission_request_tool_input_redacts_sensitive_keys() -> None: dumped = dump(queue.events[0]) tool_input = dumped["metadata"]["iac_code"]["permission"]["toolInput"] - assert tool_input == { - "cmd": "pwd", - "api_key": "***", - "nested": [{"accessKeySecret": "***"}], - "headers": {"Authorization": "***"}, + assert tool_input["cmd"] == { + "type": "str", + "length": len("pwd"), + "fingerprint": fingerprint_text("pwd"), } + assert tool_input[fingerprint_text("api_key")] == {"redacted": True} + assert tool_input[fingerprint_text("nested")] == {"type": "array", "length": 1} + assert tool_input["headers"][fingerprint_text("Authorization")] == {"redacted": True} rendered = str(tool_input) + assert "api_key" not in rendered + assert "nested" not in rendered + assert "Authorization" not in rendered assert "plain-api-key" not in rendered assert "nested-access-key-secret" not in rendered assert "auth-token-secret" not in rendered @@ -196,6 +296,46 @@ async def approve(request: PermissionRequestEvent) -> bool: assert dumped["metadata"]["iac_code"]["permission"]["autoApproved"] is True +@pytest.mark.asyncio +async def test_wrapped_permission_request_uses_inner_event() -> None: + queue = FakeEventQueue() + future = pending_future() + inner = PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="tool-1", + response_future=future, + ) + event = SubPipelineStreamEvent(sub_pipeline_id="candidate-1", candidate_index=0, inner=inner) + seen: list[PermissionRequestEvent] = [] + + async def approve(request: PermissionRequestEvent) -> bool: + seen.append(request) + return True + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=event, + permission_resolver=approve, + ) + + assert seen == [inner] + assert future.done() + assert future.result() is True + dumped = dump(queue.events[0]) + permission = dumped["metadata"]["iac_code"]["permission"] + assert permission["autoApproved"] is True + assert permission["toolName"] == "bash" + assert permission["toolUseId"] == "tool-1" + assert permission["toolInput"]["cmd"] == { + "type": "str", + "length": 3, + "fingerprint": fingerprint_text("pwd"), + } + + @pytest.mark.asyncio async def test_unknown_event_is_skipped() -> None: queue = FakeEventQueue() @@ -356,11 +496,38 @@ async def test_tool_events_publish_metadata_updates() -> None: dumped = [dump(event) for event in queue.events] assert dumped[0]["metadata"]["iac_code"]["tool"]["status"] == "started" assert dumped[1]["metadata"]["iac_code"]["tool"]["status"] == "input_delta" + assert "partialJson" not in dumped[1]["metadata"]["iac_code"]["tool"] + assert dumped[1]["metadata"]["iac_code"]["tool"]["partialJsonLength"] == 6 assert dumped[2]["metadata"]["iac_code"]["tool"]["status"] == "input_complete" assert dumped[2]["metadata"]["iac_code"]["tool"]["name"] == "bash" + assert dumped[2]["metadata"]["iac_code"]["tool"]["inputSummary"] == { + "tool_name": "bash", + "fields": {"cmd": {"type": "str"}}, + } + assert "input" not in dumped[2]["metadata"]["iac_code"]["tool"] assert dumped[3]["metadata"]["iac_code"]["tool"]["status"] == "completed" +@pytest.mark.asyncio +async def test_tool_input_delta_metadata_omits_raw_partial_json() -> None: + queue = FakeEventQueue() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolInputDeltaEvent(tool_use_id="tool-1", partial_json='ature":"signature-secret"'), + ) + + dumped = dump(queue.events[0]) + tool_metadata = dumped["metadata"]["iac_code"]["tool"] + rendered = str(tool_metadata) + assert tool_metadata["status"] == "input_delta" + assert tool_metadata["partialJsonLength"] == len('ature":"signature-secret"') + assert "partialJson" not in tool_metadata + assert "signature-secret" not in rendered + + @pytest.mark.asyncio async def test_tool_use_input_metadata_redacts_secret_values() -> None: queue = FakeEventQueue() @@ -377,12 +544,99 @@ async def test_tool_use_input_metadata_redacts_secret_values() -> None: ) dumped = dump(queue.events[0]) - tool_input = dumped["metadata"]["iac_code"]["tool"]["input"] - assert "sk-live-secret" not in str(tool_input) - assert "Authorization: Bearer" not in str(tool_input) - assert "/Users/alice" not in str(tool_input) - assert "[REDACTED]" in str(tool_input) - assert "[PATH]" in str(tool_input) + tool = dumped["metadata"]["iac_code"]["tool"] + rendered = str(tool) + assert "input" not in tool + assert tool["inputSummary"] == {"tool_name": "bash", "fields": {"cmd": {"type": "str"}}} + assert "sk-live-secret" not in rendered + assert "Authorization: Bearer" not in rendered + assert "/Users/alice" not in rendered + + +@pytest.mark.asyncio +async def test_tool_use_input_metadata_redacts_structured_secret_fields() -> None: + queue = FakeEventQueue() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolUseEndEvent( + tool_use_id="tool-1", + name="bash", + input={ + "product": "ros", + "action": "CreateStack", + "params": { + "StackName": "demo", + "AccessKeySecret": "secret-value", + "Signature": "signature-secret", + "private_key": "private-secret", + "Authorization": "Bearer bearer-secret", + "apiKey": "api-secret", + }, + }, + ), + ) + + dumped = dump(queue.events[0]) + tool = dumped["metadata"]["iac_code"]["tool"] + tool_input = tool["inputSummary"]["fields"] + for forbidden in ( + "secret-value", + "signature-secret", + "private-secret", + "bearer-secret", + "api-secret", + "AccessKeySecret", + "Signature", + "private_key", + "Authorization", + "apiKey", + ): + assert forbidden not in str(tool_input) + assert "input" not in tool + assert tool_input["params"]["fields"][fingerprint_text("StackName")] == {"type": "str"} + assert tool_input["params"]["fields"][fingerprint_text("AccessKeySecret")] == {"type": "str"} + assert tool_input["params"]["fields"][fingerprint_text("Signature")] == {"type": "str"} + assert tool_input["params"]["fields"][fingerprint_text("private_key")] == {"type": "str"} + assert tool_input["params"]["fields"][fingerprint_text("Authorization")] == {"type": "str"} + assert tool_input["params"]["fields"][fingerprint_text("apiKey")] == {"type": "str"} + + +@pytest.mark.asyncio +async def test_aliyun_tool_use_input_metadata_uses_summary_for_sensitive_safe_fields() -> None: + queue = FakeEventQueue() + pem = "-----BEGIN PRIVATE KEY-----\nprivate-body\n-----END PRIVATE KEY-----" + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolUseEndEvent( + tool_use_id="tool-1", + name="aliyun_api", + input={ + "product": "ros", + "action": "CreateStack", + "params": {"TemplateBody": pem, "StackName": "demo"}, + }, + ), + ) + + dumped = dump(queue.events[0]) + tool = dumped["metadata"]["iac_code"]["tool"] + rendered = str(tool) + assert "input" not in tool + assert tool["inputSummary"]["tool_name"] == "aliyun_api" + assert tool["inputSummary"]["params_fields"] == sorted( + [fingerprint_text("StackName"), fingerprint_text("TemplateBody")] + ) + assert tool["inputSummary"]["params_field_count"] == 2 + assert "StackName" not in rendered + assert "TemplateBody" not in rendered + assert "private-body" not in rendered + assert "BEGIN PRIVATE KEY" not in rendered @pytest.mark.asyncio @@ -406,14 +660,18 @@ async def test_tool_use_input_metadata_redacts_sensitive_keys() -> None: ) dumped = dump(queue.events[0]) - tool_input = dumped["metadata"]["iac_code"]["tool"]["input"] - assert tool_input == { - "cmd": "pwd", - "apiKey": "***", - "env": {"ALIBABA_CLOUD_ACCESS_KEY_SECRET": "***"}, - "headers": [{"x-acs-security-token": "***"}], - } - rendered = str(tool_input) + tool = dumped["metadata"]["iac_code"]["tool"] + assert "input" not in tool + summary = tool["inputSummary"] + fields = summary["fields"] + assert summary["tool_name"] == "bash" + assert fields["cmd"] == {"type": "str"} + assert fields[fingerprint_text("apiKey")] == {"type": "str"} + assert fields[fingerprint_text("env")]["type"] == "object" + assert fields["headers"] == {"type": "array", "length": 1} + rendered = str(tool) + assert "apiKey" not in rendered + assert "env" not in rendered assert "plain-api-key" not in rendered assert "ak-secret" not in rendered assert "sts-token" not in rendered @@ -436,9 +694,11 @@ async def test_tool_use_input_metadata_redacts_malformed_opaque_artifact_uri() - ) dumped = dump(queue.events[0]) - tool_input = dumped["metadata"]["iac_code"]["tool"]["input"] - rendered = str(tool_input) - assert "[PATH]" in rendered + tool = dumped["metadata"]["iac_code"]["tool"] + rendered = str(tool) + assert "input" not in tool + assert tool["inputSummary"]["fields"]["cmd"] == {"type": "str"} + assert tool["inputSummary"]["fields"][fingerprint_text("note")] == {"type": "str"} assert "iac-code-artifac[PATH]" not in rendered assert "Users" not in rendered assert ".iac-code" not in rendered @@ -461,12 +721,47 @@ async def test_tool_use_input_metadata_redacts_percent_encoded_local_path() -> N ) dumped = dump(queue.events[0]) - rendered = str(dumped["metadata"]["iac_code"]["tool"]["input"]) - assert "[PATH]" in rendered + tool = dumped["metadata"]["iac_code"]["tool"] + rendered = str(tool) + assert "input" not in tool + assert tool["inputSummary"]["fields"]["cmd"] == {"type": "str"} assert "%2FUsers" not in rendered assert ".iac-code" not in rendered +@pytest.mark.asyncio +async def test_tool_use_input_summary_fingerprints_business_field_names() -> None: + queue = FakeEventQueue() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolUseEndEvent( + tool_use_id="tool-1", + name="bash", + input={ + "cmd": "git status", + "customerEmail": "alice@example.com", + "customer-prod-123": "tenant-id", + }, + ), + ) + + tool = dump(queue.events[0])["metadata"]["iac_code"]["tool"] + rendered = str(tool) + assert "input" not in tool + assert tool["inputSummary"]["tool_name"] == "bash" + fields = tool["inputSummary"]["fields"] + assert fields["cmd"] == {"type": "str"} + assert fields[fingerprint_text("customerEmail")] == {"type": "str"} + assert fields[fingerprint_text("customer-prod-123")] == {"type": "str"} + assert "customerEmail" not in rendered + assert "customer-prod-123" not in rendered + assert "alice@example.com" not in rendered + assert "tenant-id" not in rendered + + @pytest.mark.asyncio async def test_failed_tool_result_metadata_is_sanitized() -> None: queue = FakeEventQueue() diff --git a/tests/a2a/test_permission_audit.py b/tests/a2a/test_permission_audit.py new file mode 100644 index 00000000..83069000 --- /dev/null +++ b/tests/a2a/test_permission_audit.py @@ -0,0 +1,611 @@ +"""Permission audit coverage for A2A automatic boundaries.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import pytest + +from iac_code.a2a.artifacts import A2AArtifactStore +from iac_code.a2a.events import publish_stream_event +from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator, safe_permission_metadata +from iac_code.a2a.pipeline_journal import A2APipelineJournal +from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore +from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings +from iac_code.types.stream_events import PermissionRequestEvent + +from .fakes import FakeEventQueue + + +def _publisher( + tmp_path: Path, + *, + artifact_store: A2AArtifactStore | None = None, + exposure_types: object | None = None, + a2a_artifacts_by_step_id: dict[str, list[dict[str, str]]] | None = None, +) -> tuple[PipelineA2AEventPublisher, FakeEventQueue]: + queue = FakeEventQueue() + context = PipelineA2AContext( + pipeline_run_id="run-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + parent_step_order=["evaluate_candidates", "confirm_and_select"], + candidate_step_order=["template_generating"], + a2a_artifacts_by_step_id=a2a_artifacts_by_step_id or {}, + ) + pipeline_dir = tmp_path / "pipeline" + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator(context), + journal=A2APipelineJournal(pipeline_dir), + snapshot_store=A2APipelineSnapshotStore(pipeline_dir), + artifact_store=artifact_store, + exposure_types=exposure_types, + ) + return publisher, queue + + +@pytest.mark.asyncio +async def test_stream_event_auto_approve_is_audited(monkeypatch: pytest.MonkeyPatch) -> None: + queue = FakeEventQueue() + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append((record, settings)), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + settings = PermissionAuditSettings(include_tool_input=True) + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd", "apiKey": "secret-value"}, + tool_use_id="toolu-stream-approve", + response_future=future, + audit_context={ + "session_id": "session-a2a", + "settings": settings, + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + rule="bash(pwd)", + reason_type="rule", + reason_detail="matched ask rule: bash(pwd)", + is_read_only=False, + operation={"is_read_only": False}, + ), + }, + ), + auto_approve_permissions=True, + ) + + assert future.result() is True + assert len(audit_records) == 1 + record, settings_seen = audit_records[0] + assert settings_seen is settings + assert record.session_id == "session-a2a" + assert record.source == "a2a_auto_approve" + assert record.scope == "auto_approve" + assert record.decision == "allow" + assert record.rule_source == "user_settings" + assert record.rule == "bash(pwd)" + assert record.operation == {"is_read_only": False} + assert record.tool_input_redacted == { + "cmd": {"type": "str", "length": 3, "fingerprint": fingerprint_text("pwd")}, + fingerprint_text("apiKey"): {"redacted": True}, + } + + +@pytest.mark.asyncio +async def test_stream_event_auto_approve_denies_when_audit_log_write_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + queue = FakeEventQueue() + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", _raise_audit_write_error) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="toolu-stream-approve-audit-fail", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + rule="bash(pwd)", + reason_type="rule", + reason_detail="matched allow rule: bash(pwd)", + is_read_only=False, + operation={"is_read_only": False}, + ) + }, + ), + auto_approve_permissions=True, + ) + + assert future.result() is False + + +@pytest.mark.asyncio +async def test_stream_event_auto_approve_denies_untrusted_aliyun_write(monkeypatch: pytest.MonkeyPatch) -> None: + queue = FakeEventQueue() + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-stream-write", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ), + auto_approve_permissions=True, + ) + + assert future.result() is False + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "a2a_auto_deny" + assert record.scope == "auto_deny" + assert record.decision == "deny" + assert record.reason_type == "untrusted_write" + + +@pytest.mark.asyncio +async def test_stream_event_auto_deny_is_audited(monkeypatch: pytest.MonkeyPatch) -> None: + queue = FakeEventQueue() + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "deploy"}, + tool_use_id="toolu-stream-deny", + response_future=future, + ), + auto_approve_permissions=False, + ) + + assert future.result() is False + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "a2a_auto_deny" + assert record.scope == "auto_deny" + assert record.decision == "deny" + + +@pytest.mark.asyncio +async def test_stream_event_resolver_decision_is_audited(monkeypatch: pytest.MonkeyPatch) -> None: + queue = FakeEventQueue() + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-stream-resolver", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack"}, + ) + }, + ), + permission_resolver=lambda _request: True, + auto_approve_permissions=False, + ) + + assert future.result() is True + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "a2a_resolver" + assert record.scope == "a2a_resolver" + assert record.decision == "allow" + assert record.reason_type == "a2a_resolver" + assert record.trigger_reason_type == "untrusted_write" + + +@pytest.mark.asyncio +async def test_stream_event_resolver_aliyun_write_denies_when_audit_log_write_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + queue = FakeEventQueue() + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", _raise_audit_write_error) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-stream-resolver-audit-fail", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ), + permission_resolver=lambda _request: True, + auto_approve_permissions=False, + ) + + assert future.result() is False + + +@pytest.mark.asyncio +async def test_stream_event_completed_future_is_not_auto_audited(monkeypatch: pytest.MonkeyPatch) -> None: + queue = FakeEventQueue() + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + future.set_result(False) + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="toolu-stream-done", + response_future=future, + ), + auto_approve_permissions=True, + ) + + assert future.result() is False + assert audit_records == [] + + +@pytest.mark.asyncio +async def test_a2a_auto_approve_is_audited(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + publisher, _queue = _publisher(tmp_path) + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publisher.publish( + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="toolu-approve", + response_future=future, + ), + auto_approve_permissions=True, + ) + + assert future.result() is True + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "a2a_auto_approve" + assert record.scope == "auto_approve" + assert record.decision == "allow" + + +@pytest.mark.asyncio +async def test_a2a_auto_approve_denies_when_audit_log_write_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, _queue = _publisher(tmp_path) + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", _raise_audit_write_error) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publisher.publish( + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="toolu-approve-audit-fail", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + rule="bash(pwd)", + reason_type="rule", + reason_detail="matched allow rule: bash(pwd)", + is_read_only=False, + operation={"is_read_only": False}, + ) + }, + ), + auto_approve_permissions=True, + ) + + assert future.result() is False + permission = publisher.journal.read_all()[0]["permission"] + assert permission["approved"] is False + assert permission["decision"] == "deny" + + +@pytest.mark.asyncio +async def test_a2a_auto_deny_is_audited(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + publisher, _queue = _publisher(tmp_path) + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publisher.publish( + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "deploy"}, + tool_use_id="toolu-deny", + response_future=future, + ), + auto_approve_permissions=False, + ) + + assert future.result() is False + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "a2a_auto_deny" + assert record.scope == "auto_deny" + assert record.decision == "deny" + + +@pytest.mark.asyncio +async def test_a2a_resolver_decision_is_audited( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, _queue = _publisher(tmp_path) + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publisher.publish( + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="toolu-pipeline-resolver", + response_future=future, + ), + permission_resolver=lambda _request: False, + auto_approve_permissions=True, + ) + + assert future.result() is False + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "a2a_resolver" + assert record.scope == "a2a_resolver" + assert record.decision == "deny" + + +@pytest.mark.asyncio +async def test_a2a_resolver_aliyun_write_denies_when_audit_log_write_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, _queue = _publisher(tmp_path) + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", _raise_audit_write_error) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + await publisher.publish( + PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-pipeline-resolver-audit-fail", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ), + permission_resolver=lambda _request: True, + auto_approve_permissions=False, + ) + + assert future.result() is False + permission = publisher.journal.read_all()[0]["permission"] + assert permission["approved"] is False + assert permission["decision"] == "deny" + + +@pytest.mark.asyncio +async def test_a2a_auto_approve_persistence_failure_audits_applied_deny( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, _queue = _publisher(tmp_path) + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + def fail_read_all() -> list[dict[str, Any]]: + raise OSError("read failed") + + publisher.journal.read_all_repairing_tail = fail_read_all # type: ignore[method-assign] + + await publisher.publish( + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "deploy"}, + tool_use_id="toolu-persist-fail", + response_future=future, + ), + auto_approve_permissions=True, + ) + + assert future.result() is False + assert [record.decision for record in audit_records] == ["allow", "deny"] + assert audit_records[0].source == "a2a_auto_approve" + assert audit_records[1].source == "a2a_auto_persistence_failure" + assert audit_records[1].scope == "auto_deny" + assert audit_records[1].reason_type == "persistence_failure" + assert audit_records[1].reason_detail == "permission metadata persistence failed" + + +@pytest.mark.asyncio +async def test_a2a_resolver_aliyun_write_persistence_failure_audits_persistence_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, _queue = _publisher(tmp_path) + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + def fail_read_all() -> list[dict[str, Any]]: + raise OSError("read failed") + + publisher.journal.read_all_repairing_tail = fail_read_all # type: ignore[method-assign] + + await publisher.publish( + PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-pipeline-resolver-persist-fail", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ), + permission_resolver=lambda _request: True, + auto_approve_permissions=False, + ) + + assert future.result() is False + assert [record.decision for record in audit_records] == ["allow", "deny"] + assert audit_records[1].source == "a2a_resolver_persistence_failure" + assert audit_records[1].reason_type == "persistence_failure" + assert audit_records[1].reason_detail == "permission metadata persistence failed" + + +@pytest.mark.asyncio +async def test_a2a_completed_future_is_not_auto_audited(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + publisher, _queue = _publisher(tmp_path) + audit_records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + future.set_result(False) + + await publisher.publish( + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="toolu-pipeline-done", + response_future=future, + ), + auto_approve_permissions=True, + ) + + assert future.result() is False + assert audit_records == [] + + +def test_safe_permission_metadata_excludes_audit_context() -> None: + event = PermissionRequestEvent( + tool_name="bash", + tool_input={"command": "pwd"}, + tool_use_id="toolu-safe", + ) + event.audit_context = {"settings": {"max_file_bytes": 1}, "metadata": {"rule": "write_file"}} + + metadata: dict[str, Any] = safe_permission_metadata(event) + + assert "audit_context" not in metadata + assert "settings" not in metadata + assert "rule" not in metadata + assert "settings" not in str(metadata) + assert "rule" not in str(metadata) + + +def _raise_audit_write_error(*_args: Any, **_kwargs: Any) -> None: + raise OSError("disk full") diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index 401bc186..bd81c061 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -8,6 +8,7 @@ from iac_code.a2a.pipeline_events import PIPELINE_EVENTS_EXTENSION_URI, PipelineA2AContext, PipelineEventTranslator from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.services.permissions.audit import fingerprint_text from iac_code.types.stream_events import ( CandidateDetailEvent, DiagramEvent, @@ -31,6 +32,14 @@ def _ctx() -> PipelineA2AContext: ) +def _has_truncated_object(value: object) -> bool: + if isinstance(value, dict): + if value.get("type") == "object" and value.get("truncated") is True: + return True + return any(_has_truncated_object(child) for child in value.values()) + return False + + def test_pipeline_started_has_stable_envelope() -> None: translator = PipelineEventTranslator(_ctx()) event = PipelineEvent( @@ -566,6 +575,45 @@ def test_candidate_stream_text_has_parent_and_candidate_coordinates() -> None: assert envelopes[0]["data"]["text"] == "hello" +def test_nested_sub_pipeline_permission_request_uses_inner_candidate_scope() -> None: + translator = PipelineEventTranslator(_ctx()) + translator.translate( + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=time.time(), + data={ + "sub_pipeline_id": "evaluate_candidate_inner", + "candidate_index": 0, + "candidate_name": "candidate", + "parent_step_id": "evaluate_candidates", + }, + ) + ) + + envelopes = translator.translate( + SubPipelineStreamEvent( + sub_pipeline_id="evaluate_candidate_outer", + candidate_index=1, + inner=SubPipelineStreamEvent( + sub_pipeline_id="evaluate_candidate_inner", + candidate_index=0, + inner=PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-nested", + ), + ), + ) + ) + + assert envelopes[0]["eventType"] == "permission_requested" + assert envelopes[0]["scope"] == "candidate" + assert envelopes[0]["candidate"]["runId"] == "candidate-evaluate_candidate_inner-0-1" + assert envelopes[0]["permission"]["toolName"] == "aliyun_api" + assert envelopes[0]["permission"]["inputSummary"]["tool_name"] == "aliyun_api" + + def test_candidate_started_includes_candidate_step_skeleton() -> None: translator = PipelineEventTranslator(_ctx()) @@ -1251,7 +1299,7 @@ def test_stack_current_changed_does_not_clear_current_stack_after_failed_delete( assert "cleared" not in stack_event["data"] -def test_permission_request_metadata_redacts_and_truncates_tool_input_size_and_depth() -> None: +def test_permission_request_metadata_uses_shape_only_tool_input() -> None: nested: object = "leaf" for _ in range(80): nested = {"next": nested} @@ -1265,31 +1313,62 @@ def test_permission_request_metadata_redacts_and_truncates_tool_input_size_and_d ) )[0] - assert envelope["permission"]["safeSummary"] == "bash permission request (fields: [redacted], cmd, nested)" + assert envelope["permission"]["safeSummary"] == ( + "bash permission request (fields: [redacted], cmd, {})".format(fingerprint_text("nested")) + ) tool_input = envelope["permission"]["toolInput"] - assert len(tool_input["cmd"]) == 4000 - assert tool_input["api_key"] == "[redacted]" - current = tool_input["nested"] - while isinstance(current, dict): - current = current["next"] - assert current == "[truncated-depth]" + assert tool_input["cmd"] == { + "type": "str", + "length": 5000, + "fingerprint": fingerprint_text("x" * 5000), + } + assert tool_input[fingerprint_text("api_key")] == {"redacted": True} + assert _has_truncated_object(tool_input[fingerprint_text("nested")]) assert "secret-value" not in str(envelope) +def test_permission_request_safe_summary_fingerprints_business_field_names() -> None: + translator = PipelineEventTranslator(_ctx()) + + envelope = translator.translate( + PermissionRequestEvent( + tool_name="bash", + tool_input={ + "/Users/alice/.iac-code/settings.yml": "value", + "alice@example.com": "value", + "customer-prod-123": "value", + "token=secret-token": "value", + "command": "git status", + }, + tool_use_id="toolu-1", + ) + )[0] + + summary = envelope["permission"]["safeSummary"] + assert "command" in summary + assert "[redacted]" in summary + assert fingerprint_text("/Users/alice/.iac-code/settings.yml") in summary + assert fingerprint_text("alice@example.com") in summary + assert fingerprint_text("customer-prod-123") in summary + assert "/Users/alice" not in summary + assert "alice@example.com" not in summary + assert "customer-prod-123" not in summary + assert "token=secret-token" not in summary + + def test_permission_request_metadata_redacts_secret_strings_in_safe_keys() -> None: translator = PipelineEventTranslator(_ctx()) malformed_uri = r"iac-code-artifact://artifact-1/C:\Users\alice\.iac-code\projects\demo\template.yaml" encoded_path = "file%3A%2F%2F%2FUsers%2Falice%2F.iac-code%2Fprojects%2Fdemo%2Ftemplate.yaml" + cmd = ( + f"cat /Users/alice/.iac-code/settings.yml && cat {malformed_uri} && cat {encoded_path} " + '&& curl -H "Authorization: Bearer sk-live-secret"' + ) envelope = translator.translate( PermissionRequestEvent( tool_name="bash", - tool_input={ - "cmd": ( - f"cat /Users/alice/.iac-code/settings.yml && cat {malformed_uri} && cat {encoded_path} " - '&& curl -H "Authorization: Bearer sk-live-secret"' - ) - }, + tool_input={"cmd": cmd}, tool_use_id="toolu-1", ) )[0] @@ -1298,14 +1377,49 @@ def test_permission_request_metadata_redacts_secret_strings_in_safe_keys() -> No assert "sk-live-secret" not in str(tool_input) assert "Authorization: Bearer" not in str(tool_input) assert "/Users/alice" not in str(tool_input) - assert "[REDACTED]" in str(tool_input) - assert "[PATH]" in str(tool_input) - assert "iac-code-artifac[PATH]" not in str(tool_input) + assert tool_input["cmd"] == { + "type": "str", + "length": len(cmd), + "fingerprint": fingerprint_text(cmd), + } assert "%2FUsers" not in str(tool_input) assert "Users" not in str(tool_input) -@pytest.mark.parametrize("sensitive_key", ["pwd", "passphrase", "auth", "cookie", "session", "session_id"]) +def test_aliyun_permission_request_metadata_uses_summary_for_sensitive_safe_fields() -> None: + translator = PipelineEventTranslator(_ctx()) + pem = "-----BEGIN PRIVATE KEY-----\nprivate-body\n-----END PRIVATE KEY-----" + + envelope = translator.translate( + PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={ + "product": "ros", + "action": "CreateStack", + "params": {"TemplateBody": pem, "StackName": "demo"}, + }, + tool_use_id="toolu-1", + ) + )[0] + + permission = envelope["permission"] + rendered = str(permission) + assert "toolInput" not in permission + assert permission["inputSummary"]["tool_name"] == "aliyun_api" + assert permission["inputSummary"]["params_fields"] == sorted( + [fingerprint_text("StackName"), fingerprint_text("TemplateBody")] + ) + assert permission["inputSummary"]["params_field_count"] == 2 + assert "StackName" not in rendered + assert "TemplateBody" not in rendered + assert "private-body" not in rendered + assert "BEGIN PRIVATE KEY" not in rendered + + +@pytest.mark.parametrize( + "sensitive_key", + ["pwd", "passphrase", "auth", "cookie", "session", "session_id", "private_key", "Signature"], +) def test_permission_request_metadata_redacts_common_sensitive_key_aliases(sensitive_key: str) -> None: translator = PipelineEventTranslator(_ctx()) @@ -1318,8 +1432,9 @@ def test_permission_request_metadata_redacts_common_sensitive_key_aliases(sensit )[0] tool_input = envelope["permission"]["toolInput"] - assert tool_input[sensitive_key] == "[redacted]" - assert tool_input["nested"][0]["Authorization"] == "[redacted]" + assert tool_input[fingerprint_text(sensitive_key)] == {"redacted": True} + assert tool_input[fingerprint_text("nested")] == {"type": "array", "length": 1} + assert sensitive_key not in str(tool_input) assert "secret-value" not in str(envelope) @@ -1334,13 +1449,10 @@ def test_permission_request_safe_summary_caps_field_names() -> None: ) )[0] - assert envelope["permission"]["safeSummary"] == ( - "bash permission request (fields: " - "field_00, field_01, field_02, field_03, field_04, " - "field_05, field_06, field_07, field_08, field_09, " - "field_10, field_11, field_12, field_13, field_14, " - "field_15, field_16, field_17, field_18, field_19, +5 more)" - ) + summary = envelope["permission"]["safeSummary"] + assert "field_00" not in summary + assert "sha256:" in summary + assert len(summary) <= 256 def test_permission_request_safe_summary_caps_total_length() -> None: diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py index 3a14f93c..42c6ece7 100644 --- a/tests/a2a/test_pipeline_stream.py +++ b/tests/a2a/test_pipeline_stream.py @@ -17,6 +17,8 @@ from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher, is_recovery_semantic_event from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.types.permissions import PermissionAuditMetadata from iac_code.types.stream_events import ( AskUserQuestionEvent, CandidateDetailEvent, @@ -34,6 +36,14 @@ def dump(event: Any) -> dict[str, Any]: return MessageToDict(event, preserving_proto_field_name=False) +def _has_truncated_object(value: object) -> bool: + if isinstance(value, dict): + if value.get("type") == "object" and value.get("truncated") is True: + return True + return any(_has_truncated_object(child) for child in value.values()) + return False + + def _publisher( tmp_path: Path, *, @@ -250,12 +260,68 @@ async def test_publish_sub_pipeline_permission_resolves_inner_future_and_publish assert permission["toolName"] == "bash" assert permission["toolUseId"] == "toolu-1" assert permission["safeSummary"] == "bash permission request (fields: cmd)" - assert permission["toolInput"] == {"cmd": "pwd"} + assert permission["toolInput"] == {"cmd": {"type": "str", "length": 3, "fingerprint": fingerprint_text("pwd")}} assert permission["approved"] is True assert permission["decision"] == "allow_once" assert publisher.journal.read_all()[-1]["permission"]["approved"] is True +@pytest.mark.asyncio +async def test_publish_nested_sub_pipeline_permission_resolves_inner_future(tmp_path: Path) -> None: + publisher, queue = _publisher(tmp_path) + await publisher.publish( + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=1717821600.0, + data={ + "sub_pipeline_id": "eval-inner", + "candidate_index": 0, + "candidate_name": "candidate", + "parent_step_id": "evaluate_candidates", + "total_steps": 1, + }, + ) + ) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + returned = await publisher.publish( + SubPipelineStreamEvent( + sub_pipeline_id="eval-outer", + candidate_index=1, + inner=SubPipelineStreamEvent( + sub_pipeline_id="eval-inner", + candidate_index=0, + inner=PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-nested", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ), + ), + ), + auto_approve_permissions=False, + ) + + assert returned is None + assert future.result() is False + permission = dump(queue.events[-1])["metadata"]["iac_code"]["pipeline"]["permission"] + assert permission["toolName"] == "aliyun_api" + assert permission["toolUseId"] == "toolu-nested" + assert permission["inputSummary"]["tool_name"] == "aliyun_api" + assert permission["approved"] is False + + @pytest.mark.asyncio async def test_publish_direct_permission_resolver_overrides_auto_approve(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) @@ -280,6 +346,39 @@ async def test_publish_direct_permission_resolver_overrides_auto_approve(tmp_pat assert permission["toolUseId"] == "toolu-direct" +@pytest.mark.asyncio +async def test_publish_direct_auto_approve_denies_untrusted_aliyun_write(tmp_path: Path) -> None: + publisher, queue = _publisher(tmp_path) + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + returned = await publisher.publish( + PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="toolu-aliyun-write", + response_future=future, + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ), + auto_approve_permissions=True, + ) + + assert returned is None + assert future.result() is False + permission = dump(queue.events[0])["metadata"]["iac_code"]["pipeline"]["permission"] + assert permission["approved"] is False + assert permission["decision"] == "deny" + assert permission["toolUseId"] == "toolu-aliyun-write" + + @pytest.mark.asyncio async def test_publish_permission_denies_future_when_pipeline_metadata_is_not_persisted(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) @@ -432,7 +531,12 @@ async def test_publish_permission_redacts_and_truncates_tool_input_in_status_met await publisher.publish( PermissionRequestEvent( tool_name="bash", - tool_input={"cmd": "x" * 5000, "apiKey": "secret-value", "nested": nested}, + tool_input={ + "cmd": "x" * 5000, + "apiKey": "secret-value", + "Signature": "signature-secret", + "nested": nested, + }, tool_use_id="toolu-large", ), auto_approve_permissions=True, @@ -441,14 +545,20 @@ async def test_publish_permission_redacts_and_truncates_tool_input_in_status_met status_tool_input = dump(queue.events[0])["metadata"]["iac_code"]["pipeline"]["permission"]["toolInput"] journal_tool_input = publisher.journal.read_all()[0]["permission"]["toolInput"] for tool_input in (status_tool_input, journal_tool_input): - assert len(tool_input["cmd"]) == 4000 - assert tool_input["apiKey"] == "[redacted]" - current = tool_input["nested"] - while isinstance(current, dict): - current = current["next"] - assert current == "[truncated-depth]" + assert tool_input["cmd"] == { + "type": "str", + "length": 5000, + "fingerprint": fingerprint_text("x" * 5000), + } + assert tool_input[fingerprint_text("apiKey")] == {"redacted": True} + assert tool_input[fingerprint_text("Signature")] == {"redacted": True} + assert "apiKey" not in str(tool_input) + assert "Signature" not in str(tool_input) + assert _has_truncated_object(tool_input[fingerprint_text("nested")]) assert "secret-value" not in str(dump(queue.events[0])) + assert "signature-secret" not in str(dump(queue.events[0])) assert "secret-value" not in str(publisher.journal.read_all()[0]) + assert "signature-secret" not in str(publisher.journal.read_all()[0]) @pytest.mark.asyncio diff --git a/tests/acp/test_convert.py b/tests/acp/test_convert.py index 072bbded..1bd8dac2 100644 --- a/tests/acp/test_convert.py +++ b/tests/acp/test_convert.py @@ -58,12 +58,38 @@ def test_tool_input_delta_event_accumulates_args() -> None: assert len(updates1) == 1 assert updates1[0].session_update == "tool_call_update" assert updates1[0].status == "pending" - # First chunk only - assert updates1[0].content[0].content.text == '{"cmd":' + assert updates1[0].content[0].content.text == "Input received (7 chars)" assert len(updates2) == 1 - # Accumulated - assert updates2[0].content[0].content.text == '{"cmd": "ls"}' + assert updates2[0].content[0].content.text == "Input received (13 chars)" + + +def test_tool_input_delta_event_omits_raw_partial_json() -> None: + converter = ACPEventConverter(turn_id="turn-1") + converter.event_to_updates(ToolUseStartEvent(tool_use_id="t1", name="aliyun_api")) + + [update] = converter.event_to_updates( + ToolInputDeltaEvent(tool_use_id="t1", partial_json='ature":"signature-secret"') + ) + + rendered = update.content[0].content.text + assert rendered == "Input received (25 chars)" + assert "signature-secret" not in rendered + + +def test_tool_input_delta_event_omits_secret_from_progress_title() -> None: + turn_state = TurnState(turn_id="turn-1") + converter = ACPEventConverter(turn_id="turn-1", turn_state=turn_state) + converter.event_to_updates(ToolUseStartEvent(tool_use_id="t1", name="bash")) + + [update] = converter.event_to_updates( + ToolInputDeltaEvent(tool_use_id="t1", partial_json='{"command": "curl -H Authorization:Bearer abc123"}') + ) + + rendered = str(update.model_dump(mode="json")) + assert "Authorization" not in rendered + assert "abc123" not in rendered + assert update.title == "bash" # --------------------------------------------------------------------------- @@ -147,6 +173,7 @@ def test_tool_call_full_lifecycle_events() -> None: end_updates = converter.event_to_updates(ToolUseEndEvent(tool_use_id="t1", name="read_file", input={"path": "x"})) assert len(end_updates) == 1 assert end_updates[0].status == "in_progress" + assert "Input summary:" in end_updates[0].content[0].content.text # 4. ToolResult → progress (in_progress) + end (completed) result_updates = converter.event_to_updates( @@ -689,8 +716,8 @@ def test_tool_use_start_triggers_turn_state_start_tool_call() -> None: assert tc.tool_call_id == "tc-1" -def test_tool_input_delta_updates_tool_call_state_and_generates_title_in_progress() -> None: - """Scenario 10: ToolInputDeltaEvent updates ToolCallState, progress has computed title.""" +def test_tool_input_delta_updates_tool_call_state_without_public_raw_title() -> None: + """Scenario 10: ToolInputDeltaEvent updates ToolCallState but keeps ACP title generic.""" ts = TurnState(turn_id="turn-10") converter = ACPEventConverter(turn_id="turn-10", turn_state=ts) @@ -705,13 +732,13 @@ def test_tool_input_delta_updates_tool_call_state_and_generates_title_in_progres assert len(updates) == 1 progress = updates[0] assert progress.session_update == "tool_call_update" - assert progress.title is not None - assert "/tmp/test.tf" in progress.title + assert progress.title == "read_file" # Verify the underlying ToolCallState was updated tc = ts.get_tool_call("tc-2") assert tc is not None assert tc.accumulated_input == '{"file_path": "/tmp/test.tf"}' + assert "/tmp/test.tf" in tc.title def test_pipeline_tool_call_titles_use_display_names() -> None: diff --git a/tests/acp/test_permission_audit.py b/tests/acp/test_permission_audit.py new file mode 100644 index 00000000..b0bcb7ae --- /dev/null +++ b/tests/acp/test_permission_audit.py @@ -0,0 +1,359 @@ +"""Tests for ACP permission prompt audit behavior.""" + +from __future__ import annotations + +import asyncio +import json + +import acp +import pytest + +from iac_code.acp.session import ACPSession +from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi +from iac_code.types.permissions import ( + PermissionAuditMetadata, + PermissionAuditSettings, + PermissionResult, + PermissionRuleValue, +) +from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent + + +class FakeConn: + """Configurable fake ACP client connection for permission tests.""" + + def __init__(self, *, outcome: str = "allow_once") -> None: + self._outcome = outcome + self.permission_requests: list[dict] = [] + + async def request_permission(self, options, session_id, tool_call, **kwargs): + self.permission_requests.append( + { + "options": options, + "session_id": session_id, + "tool_call": tool_call, + } + ) + if self._outcome == "allow_rule": + option_id = next(option.option_id for option in options if option.option_id.startswith("allow_rule:")) + return acp.schema.RequestPermissionResponse( + outcome=acp.schema.AllowedOutcome(outcome="selected", option_id=option_id) + ) + if self._outcome == "deny_rule": + option_id = next(option.option_id for option in options if option.option_id.startswith("deny_rule:")) + return acp.schema.RequestPermissionResponse( + outcome=acp.schema.DeniedOutcome(outcome="cancelled"), + field_meta={"option_id": option_id}, + ) + if self._outcome in ("allow_once", "allow_always"): + return acp.schema.RequestPermissionResponse( + outcome=acp.schema.AllowedOutcome(outcome="selected", option_id=self._outcome) + ) + if self._outcome == "reject_always": + return acp.schema.RequestPermissionResponse( + outcome=acp.schema.DeniedOutcome(outcome="cancelled"), + field_meta={"option_id": "reject_always"}, + ) + return acp.schema.RequestPermissionResponse(outcome=acp.schema.DeniedOutcome(outcome="cancelled")) + + +class FakeLoopApprove: + """Minimal agent loop stand-in for direct permission tests.""" + + +class FakeLoopWithAliyun: + """Minimal agent loop with aliyun_api registered for permission option tests.""" + + def __init__(self) -> None: + aliyun_api = AliyunApi() + self.tool_registry = type( + "Registry", + (), + { + "get": lambda self, name: aliyun_api if name == "aliyun_api" else None, + "list_tools": lambda self: [aliyun_api], + }, + )() + + +class FakeLoopWrappedPermission: + def __init__(self, permission_event: PermissionRequestEvent) -> None: + self.permission_event = permission_event + + async def run_streaming(self, prompt): + yield SubPipelineStreamEvent( + sub_pipeline_id="sub-1", + candidate_index=0, + inner=self.permission_event, + ) + if self.permission_event.response_future is not None: + await self.permission_event.response_future + + +def _make_event(*, audit_context=None, permission_result: PermissionResult | None = None) -> PermissionRequestEvent: + return PermissionRequestEvent( + tool_name="write_file", + tool_input={"path": "main.tf", "content": "resource {}"}, + tool_use_id="tool1", + permission_result=permission_result, + audit_context=audit_context, + ) + + +def _make_aliyun_write_event() -> PermissionRequestEvent: + return PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tool-aliyun-write", + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ) + + +@pytest.fixture +def audit_records(monkeypatch): + records = [] + + def fake_emit(record, settings=None): + records.append((record, settings)) + + monkeypatch.setattr("iac_code.services.permissions.audit.emit_permission_audit", fake_emit) + return records + + +@pytest.mark.asyncio +async def test_acp_tool_cache_hit_uses_acp_tool_cache_source(audit_records) -> None: + conn = FakeConn(outcome="allow_once") + session = ACPSession("s1", FakeLoopApprove(), conn) + session._permission_cache["write_file"] = "always_allow" + + result = await session._request_permission(_make_event()) + + assert result is True + assert conn.permission_requests == [] + [(record, _settings)] = audit_records + assert record.source == "acp_tool_cache" + assert record.scope == "tool_cache" + assert record.rule_source == "tool_cache" + assert record.decision == "allow" + + +@pytest.mark.asyncio +async def test_acp_prompt_does_not_expose_internal_audit_context(audit_records) -> None: + conn = FakeConn(outcome="allow_once") + session = ACPSession("s1", FakeLoopApprove(), conn) + audit_context = { + "session_id": "session-secret", + "settings": PermissionAuditSettings(max_file_bytes=123, max_files=2), + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + rule="secret-rule:DoWrite", + reason_type="rule", + reason_detail="matched secret-rule:DoWrite", + is_read_only=False, + ), + } + + result = await session._request_permission(_make_event(audit_context=audit_context)) + + assert result is True + payload = conn.permission_requests[0]["tool_call"].model_dump() + serialized = json.dumps(payload, ensure_ascii=False) + assert "audit_context" not in payload + assert "audit_context" not in serialized + assert "rule_source" not in serialized + assert "secret-rule:DoWrite" not in serialized + assert "max_file_bytes" not in serialized + + +@pytest.mark.asyncio +async def test_acp_prompt_reject_always_emits_prompt_audit(audit_records) -> None: + conn = FakeConn(outcome="reject_always") + session = ACPSession("s1", FakeLoopApprove(), conn) + audit_context = { + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="write_file", + reason_type="rule", + reason_detail="matched ask rule(s): write_file", + is_read_only=False, + ) + } + + result = await session._request_permission(_make_event(audit_context=audit_context)) + + assert result is False + assert session._permission_cache["write_file"] == "always_deny" + [(record, _settings)] = audit_records + assert record.source == "acp_prompt" + assert record.scope == "tool_cache" + assert record.decision == "deny" + assert record.rule_source == "tool_cache" + assert record.reason_detail == "reject_always" + + +@pytest.mark.asyncio +async def test_acp_prompt_preserves_triggering_ask_rule_provenance(audit_records) -> None: + conn = FakeConn(outcome="allow_once") + session = ACPSession("s1", FakeLoopApprove(), conn) + audit_context = { + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="write_file", + reason_type="rule", + reason_detail="matched ask rule(s): write_file", + is_read_only=False, + ) + } + + result = await session._request_permission(_make_event(audit_context=audit_context)) + + assert result is True + [(record, _settings)] = audit_records + assert record.source == "acp_prompt" + assert record.scope == "once" + assert record.decision == "allow" + assert record.reason_type == "prompt_selection" + assert record.reason_detail == "allow_once" + assert record.rule_source == "project_settings" + assert record.rule == "write_file" + + +@pytest.mark.asyncio +async def test_acp_aliyun_write_allow_denies_when_audit_log_write_fails(monkeypatch) -> None: + def fail_append(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fail_append) + conn = FakeConn(outcome="allow_once") + session = ACPSession("s1", FakeLoopWithAliyun(), conn) + + result = await session._request_permission(_make_aliyun_write_event()) + + assert result is False + + +@pytest.mark.asyncio +async def test_acp_allow_denies_when_audit_log_write_fails(monkeypatch) -> None: + def fail_append(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fail_append) + conn = FakeConn(outcome="allow_once") + session = ACPSession("s1", FakeLoopApprove(), conn) + event = _make_event( + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + is_read_only=False, + operation={"is_read_only": False}, + ) + } + ) + + result = await session._request_permission(event) + + assert result is False + + +@pytest.mark.asyncio +async def test_acp_prompt_handles_wrapped_permission_request(audit_records) -> None: + conn = FakeConn(outcome="allow_once") + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + permission_event = _make_aliyun_write_event() + permission_event.response_future = future + session = ACPSession("s1", FakeLoopWrappedPermission(permission_event), conn) + + response = await session.prompt([acp.schema.TextContentBlock(type="text", text="create stack")]) + + assert response.stop_reason == "end_turn" + assert future.result() is True + assert len(conn.permission_requests) == 1 + [(record, _settings)] = audit_records + assert record.source == "acp_prompt" + assert record.scope == "once" + assert record.decision == "allow" + assert record.operation == {"product": "ros", "action": "CreateStack", "is_read_only": False} + + +@pytest.mark.asyncio +async def test_acp_prompt_allow_rule_emits_session_rule_audit(audit_records) -> None: + conn = FakeConn(outcome="allow_rule") + session = ACPSession("s1", FakeLoopApprove(), conn) + audit_context = { + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="write_file(path:main.tf)", + reason_type="rule", + reason_detail="matched ask rule(s): write_file(path:main.tf)", + is_read_only=False, + ) + } + permission_result = PermissionResult( + behavior="ask", + suggestions=[PermissionRuleValue(tool_name="write_file", rule_content="path:main.tf")], + ) + + result = await session._request_permission( + _make_event(audit_context=audit_context, permission_result=permission_result) + ) + + assert result is True + [(record, _settings)] = audit_records + assert record.source == "acp_prompt" + assert record.scope == "session_rule" + assert record.decision == "allow" + assert record.rule_source == "session" + assert record.rule == "write_file(path:main.tf)" + assert record.reason_detail == "allow_rule" + + +@pytest.mark.asyncio +async def test_acp_prompt_deny_rule_emits_session_rule_source(audit_records) -> None: + conn = FakeConn(outcome="deny_rule") + session = ACPSession("s1", FakeLoopApprove(), conn) + audit_context = { + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="write_file(path:main.tf)", + reason_type="rule", + reason_detail="matched ask rule(s): write_file(path:main.tf)", + is_read_only=False, + ) + } + permission_result = PermissionResult( + behavior="ask", + suggestions=[PermissionRuleValue(tool_name="write_file", rule_content="path:main.tf")], + ) + + result = await session._request_permission( + _make_event(audit_context=audit_context, permission_result=permission_result) + ) + + assert result is False + [(record, _settings)] = audit_records + assert record.source == "acp_prompt" + assert record.scope == "session_rule" + assert record.decision == "deny" + assert record.rule_source == "session" + assert record.rule == "write_file(path:main.tf)" + assert record.reason_detail == "deny_rule" diff --git a/tests/acp/test_permission_rules.py b/tests/acp/test_permission_rules.py index db801f02..aa1eec1e 100644 --- a/tests/acp/test_permission_rules.py +++ b/tests/acp/test_permission_rules.py @@ -18,8 +18,9 @@ _PREFIX_DENY_RULE, ACPSession, ) +from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi from iac_code.tools.read_file import ReadFileTool -from iac_code.types.permissions import PermissionRuleValue, ToolPermissionContext +from iac_code.types.permissions import PermissionAuditMetadata, PermissionRuleValue, ToolPermissionContext from iac_code.types.stream_events import MessageEndEvent, PermissionRequestEvent, TextDeltaEvent, Usage @@ -141,6 +142,44 @@ async def test_read_file_without_suggestions_omits_allow_always(): assert _OPTION_ALLOW_ALWAYS not in option_ids +@pytest.mark.asyncio +async def test_unoffered_allow_always_response_for_aliyun_write_is_rejected(monkeypatch): + """ACP clients cannot select a broad allow option that was not offered.""" + records = [] + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: records.append(record), + ) + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ALWAYS)) + loop = _FakeLoop() + loop.tool_registry.get.return_value = AliyunApi() + session = ACPSession("s1", loop, conn) + event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tu-aliyun-write", + audit_context={ + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ) + }, + ) + + result = await session._request_permission(event) + + option_ids = {option.option_id for option in conn.last_options} + assert _OPTION_ALLOW_ALWAYS not in option_ids + assert result is False + assert "aliyun_api" not in session._permission_cache + assert records[-1].decision == "deny" + assert records[-1].reason_detail == "invalid_option" + + # --------------------------------------------------------------------------- # Test: allow_rule response applies rule to permission_context # --------------------------------------------------------------------------- @@ -320,6 +359,195 @@ async def test_content_includes_suggested_rule(): assert "bash" in conn.last_content +@pytest.mark.asyncio +async def test_content_uses_safe_input_summary_for_aliyun_api(): + """ToolCallUpdate content does not expose raw Aliyun request input.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + event = _make_event( + tool_name="aliyun_api", + tool_input={ + "product": "ros", + "action": "CreateStack", + "params": { + "StackName": "demo", + "AccessKeySecret": "secret-value", + "Signature": "signature-secret", + "Authorization": "Bearer bearer-secret", + }, + "body": {"PrivateKey": "private-secret"}, + }, + ) + + await session._request_permission(event) + + assert "Input summary:" in conn.last_content + assert "product" in conn.last_content + assert "action" in conn.last_content + for forbidden in ( + "secret-value", + "signature-secret", + "bearer-secret", + "private-secret", + "AccessKeySecret", + "Signature", + "Authorization", + "PrivateKey", + ): + assert forbidden not in conn.last_content + + +@pytest.mark.asyncio +async def test_content_preserves_redacted_non_aliyun_tool_input(): + """Non-Aliyun permission prompts keep decision-critical input visible.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + event = _make_event( + tool_name="bash", + tool_input={"command": "git status --short", "apiKey": "secret-value"}, + ) + + await session._request_permission(event) + + assert "Input:" in conn.last_content + assert "Input summary:" not in conn.last_content + assert "git status --short" in conn.last_content + assert "secret-value" not in conn.last_content + assert "apiKey" not in conn.last_content + + +@pytest.mark.asyncio +async def test_content_fingerprints_business_fields_in_non_aliyun_tool_input(): + """Non-Aliyun permission prompts hide business field names and values.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + event = _make_event( + tool_name="bash", + tool_input={ + "command": "git status --short", + "customerEmail": "alice@example.com", + "customer-prod-123": "tenant-id", + }, + ) + + await session._request_permission(event) + + assert "git status --short" in conn.last_content + for forbidden in ("customerEmail", "customer-prod-123", "alice@example.com", "tenant-id"): + assert forbidden not in conn.last_content + + +@pytest.mark.asyncio +async def test_content_redacts_space_separated_secret_flags_and_preserves_paths(): + """Non-Aliyun permission prompts redact CLI secret flags without hiding paths.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + event = _make_event( + tool_name="bash", + tool_input={ + "command": ( + "cat /Users/alice/project/main.tf --token abc123value --password 'hunter2' --api-key sk-live-secret" + ), + "path": "/Users/alice/project/main.tf", + }, + ) + + await session._request_permission(event) + + assert "/Users/alice/project/main.tf" in conn.last_content + assert "--token [REDACTED]" in conn.last_content + assert "--password [REDACTED]" in conn.last_content + assert "--api-key [REDACTED]" in conn.last_content + for forbidden in ("abc123value", "hunter2", "sk-live-secret", "[PATH]"): + assert forbidden not in conn.last_content + + +@pytest.mark.asyncio +async def test_content_redacts_env_secret_assignments_without_false_flag_matches(): + """Non-Aliyun permission prompts redact env-style secrets without hiding paths.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + event = _make_event( + tool_name="bash", + tool_input={ + "command": ( + "OPENAI_API_KEY=sk-openai AWS_SECRET_ACCESS_KEY='aws-secret' " + "ALIBABA_CLOUD_ACCESS_KEY_SECRET=aliyun-secret " + "echo my-secret value /Users/alice/project/main.tf" + ), + "path": "/Users/alice/project/main.tf", + }, + ) + + await session._request_permission(event) + + assert "OPENAI_API_KEY=[REDACTED]" in conn.last_content + assert "AWS_SECRET_ACCESS_KEY=[REDACTED]" in conn.last_content + assert "ALIBABA_CLOUD_ACCESS_KEY_SECRET=[REDACTED]" in conn.last_content + assert "my-secret value" in conn.last_content + assert "/Users/alice/project/main.tf" in conn.last_content + for forbidden in ("sk-openai", "aws-secret", "aliyun-secret", "[PATH]"): + assert forbidden not in conn.last_content + + +@pytest.mark.asyncio +async def test_content_redacts_long_json_and_escaped_quote_secret_values(): + """Non-Aliyun permission prompts redact secrets before long-string slicing.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + long_secret = "sk-" + ("x" * 260) + "tail-secret" + event = _make_event( + tool_name="bash", + tool_input={ + "command": ( + f"OPENAI_API_KEY={long_secret} " + 'curl -d \'{"apiKey":"sk-json-secret"}\' ' + 'SERVICE_TOKEN="abc\\"escaped-tail-secret" ' + "/Users/alice/project/main.tf" + ), + "path": "/Users/alice/project/main.tf", + }, + ) + + await session._request_permission(event) + + assert "OPENAI_API_KEY=[REDACTED]" in conn.last_content + assert "apiKey" in conn.last_content + assert "/Users/alice/project/main.tf" in conn.last_content + for forbidden in (long_secret, "tail-secret", "sk-json-secret", "escaped-tail-secret", "[PATH]"): + assert forbidden not in conn.last_content + + +@pytest.mark.asyncio +async def test_content_marks_truncated_long_non_aliyun_tool_input(): + """Non-Aliyun permission prompts make long decision input truncation explicit.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + command = ("echo safe && " * 40) + "rm -rf /" + event = _make_event(tool_name="bash", tool_input={"command": command}) + + await session._request_permission(event) + + assert '"truncated": true' in conn.last_content + assert "rm -rf /" in conn.last_content + + +@pytest.mark.asyncio +async def test_content_preserves_priority_fields_in_wide_non_aliyun_tool_input(): + """Non-Aliyun permission prompts do not let low-value fields hide command/path.""" + conn = _FakeConn(_make_allowed_outcome(_OPTION_ALLOW_ONCE)) + session = ACPSession("s1", _FakeLoop(), conn) + tool_input = {f"field_{index}": index for index in range(100)} + tool_input["command"] = "rm -rf /" + event = _make_event(tool_name="bash", tool_input=tool_input) + + await session._request_permission(event) + + assert "rm -rf /" in conn.last_content + assert '"_truncated"' in conn.last_content + assert "field_99" not in conn.last_content + + @pytest.mark.asyncio async def test_content_no_suggested_rule_without_suggestions(): """ToolCallUpdate content does not include 'Suggested rule' when no suggestions.""" diff --git a/tests/acp/test_permission_rules_e2e.py b/tests/acp/test_permission_rules_e2e.py index 10508145..f5575c64 100644 --- a/tests/acp/test_permission_rules_e2e.py +++ b/tests/acp/test_permission_rules_e2e.py @@ -92,7 +92,7 @@ async def test_permission_request_shows_rule_options(): @pytest.mark.asyncio async def test_permission_content_shows_command_and_rule(): - """ToolCallUpdate content includes the command and suggested rule.""" + """ToolCallUpdate content includes redacted input and suggested rule.""" suggestions = [PermissionRuleValue(tool_name="bash", rule_content="git:*")] conn = _CapturingConn(response_option_id="allow_once") loop = _PermissionTriggeringLoop(suggestions) @@ -101,6 +101,8 @@ async def test_permission_content_shows_command_and_rule(): await session.prompt([acp.schema.TextContentBlock(type="text", text="push")]) print("\n[Content]:", conn.captured_content) + assert "Input:" in conn.captured_content + assert "command" in conn.captured_content assert "git push origin main" in conn.captured_content assert "Suggested rule: git:*" in conn.captured_content diff --git a/tests/acp/test_scenarios.py b/tests/acp/test_scenarios.py index c88fafc0..a9c1e589 100644 --- a/tests/acp/test_scenarios.py +++ b/tests/acp/test_scenarios.py @@ -334,6 +334,134 @@ def test_a4_history_message_to_updates_all_types() -> None: assert updates[1].tool_call_id == "history/0/tool-1" assert updates[1].status == "in_progress" + asst_business_tool = Message( + role="assistant", + content=[ + ToolUseBlock( + id="tool-business", + name="bash", + input={ + "cmd": "git status --short", + "customerEmail": "alice@example.com", + "customer-prod-123": "tenant-id", + }, + ) + ], + ) + updates = _history_message_to_updates(asst_business_tool) + rendered = json.dumps([update.model_dump(mode="json") for update in updates], ensure_ascii=False) + assert "git status --short" in rendered + for forbidden in ("customerEmail", "customer-prod-123", "alice@example.com", "tenant-id"): + assert forbidden not in rendered + + asst_secret_flag_tool = Message( + role="assistant", + content=[ + ToolUseBlock( + id="tool-secret-flag", + name="bash", + input={ + "cmd": ( + "cat /Users/alice/project/main.tf " + "--token abc123value --password 'hunter2' --api-key sk-live-secret" + ), + "path": "/Users/alice/project/main.tf", + }, + ) + ], + ) + updates = _history_message_to_updates(asst_secret_flag_tool) + rendered = json.dumps([update.model_dump(mode="json") for update in updates], ensure_ascii=False) + assert "/Users/alice/project/main.tf" in rendered + assert "--token [REDACTED]" in rendered + assert "--password [REDACTED]" in rendered + assert "--api-key [REDACTED]" in rendered + for forbidden in ("abc123value", "hunter2", "sk-live-secret", "[PATH]"): + assert forbidden not in rendered + + asst_env_secret_tool = Message( + role="assistant", + content=[ + ToolUseBlock( + id="tool-env-secret", + name="bash", + input={ + "cmd": ( + "OPENAI_API_KEY=sk-openai AWS_SECRET_ACCESS_KEY='aws-secret' " + "ALIBABA_CLOUD_ACCESS_KEY_SECRET=aliyun-secret " + "echo my-secret value /Users/alice/project/main.tf" + ), + "path": "/Users/alice/project/main.tf", + }, + ) + ], + ) + updates = _history_message_to_updates(asst_env_secret_tool) + rendered = json.dumps([update.model_dump(mode="json") for update in updates], ensure_ascii=False) + assert "OPENAI_API_KEY=[REDACTED]" in rendered + assert "AWS_SECRET_ACCESS_KEY=[REDACTED]" in rendered + assert "ALIBABA_CLOUD_ACCESS_KEY_SECRET=[REDACTED]" in rendered + assert "my-secret value" in rendered + assert "/Users/alice/project/main.tf" in rendered + for forbidden in ("sk-openai", "aws-secret", "aliyun-secret", "[PATH]"): + assert forbidden not in rendered + + long_env_secret = "sk-" + ("x" * 260) + "tail-secret" + asst_long_secret_tool = Message( + role="assistant", + content=[ + ToolUseBlock( + id="tool-long-secret", + name="bash", + input={ + "cmd": ( + f"OPENAI_API_KEY={long_env_secret} " + 'curl -d \'{"apiKey":"sk-json-secret"}\' ' + 'SERVICE_TOKEN="abc\\"escaped-tail-secret" ' + "/Users/alice/project/main.tf" + ), + "path": "/Users/alice/project/main.tf", + }, + ) + ], + ) + updates = _history_message_to_updates(asst_long_secret_tool) + rendered = json.dumps([update.model_dump(mode="json") for update in updates], ensure_ascii=False) + assert "OPENAI_API_KEY=[REDACTED]" in rendered + assert "apiKey" in rendered + assert "/Users/alice/project/main.tf" in rendered + for forbidden in (long_env_secret, "tail-secret", "sk-json-secret", "escaped-tail-secret", "[PATH]"): + assert forbidden not in rendered + + asst_aliyun_tool = Message( + role="assistant", + content=[ + ToolUseBlock( + id="tool-aliyun", + name="aliyun_api", + input={ + "product": "ROS", + "action": "CreateStack", + "params": { + "RegionId": "cn-hangzhou", + "AccessKeySecret": "secret-value", + "Signature": "signature-secret", + }, + "headers": {"Authorization": "Bearer bearer-secret"}, + }, + ) + ], + ) + updates = _history_message_to_updates(asst_aliyun_tool) + rendered = json.dumps([update.model_dump(mode="json") for update in updates], ensure_ascii=False) + assert "Input summary:" in rendered + assert "secret-value" not in rendered + assert "signature-secret" not in rendered + assert "bearer-secret" not in rendered + assert "AccessKeySecret" not in rendered + assert "Signature" not in rendered + assert "Authorization" not in rendered + user_result = Message( role="user", content=[ToolResultBlock(tool_use_id="tool-1", content="file list", is_error=False)], diff --git a/tests/agent/test_agent_tool_permission_audit.py b/tests/agent/test_agent_tool_permission_audit.py new file mode 100644 index 00000000..8031ee6d --- /dev/null +++ b/tests/agent/test_agent_tool_permission_audit.py @@ -0,0 +1,82 @@ +"""Permission audit coverage for AgentTool child-boundary decisions.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from iac_code.agent.agent_tool import run_sub_agent +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings +from iac_code.types.stream_events import PermissionRequestEvent + + +@pytest.mark.asyncio +async def test_agent_tool_auto_denial_is_audited(monkeypatch: pytest.MonkeyPatch) -> None: + future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + audit_records = [] + + async def fake_stream(_prompt): + yield PermissionRequestEvent( + tool_name="bash", + tool_input={"command": "rm -rf build", "accessKeyId": "secret-ak"}, + tool_use_id="toolu-child", + response_future=future, + audit_context={ + "session_id": "session-child", + "settings": PermissionAuditSettings(include_tool_input=True), + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="bash(rm:*)", + reason_type="rule", + reason_detail="matched ask rule: bash(rm:*)", + is_read_only=False, + operation={"is_read_only": False}, + ), + }, + ) + + class FakeAgentLoop: + def __init__(self, **kwargs): + self.context_manager = SimpleNamespace(get_total_tokens=lambda: 321) + + def run_streaming(self, prompt): + return fake_stream(prompt) + + monkeypatch.setattr( + "iac_code.agent.agent_tool.get_agent_definition", + lambda agent_type: SimpleNamespace(max_turns=3), + ) + monkeypatch.setattr("iac_code.agent.agent_tool.filter_tools", lambda registry, defn: "filtered-tools") + monkeypatch.setattr("iac_code.agent.system_prompt.build_system_prompt", lambda cwd=None: "built prompt") + monkeypatch.setattr("iac_code.agent.agent_loop.AgentLoop", FakeAgentLoop) + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append((record, settings)), + ) + + await run_sub_agent( + prompt="demo", + parent_provider_manager="pm", + parent_tool_registry="registry", + ) + + assert future.result() is False + assert len(audit_records) == 1 + record, settings_seen = audit_records[0] + assert settings_seen.include_tool_input is True + assert record.session_id == "session-child" + assert record.source == "agent_tool_auto_deny" + assert record.scope == "auto_deny" + assert record.decision == "deny" + assert record.rule_source == "project_settings" + assert record.rule == "bash(rm:*)" + assert record.operation == {"is_read_only": False} + assert record.tool_input_redacted == { + "command": {"type": "str", "length": 12, "fingerprint": fingerprint_text("rm -rf build")}, + fingerprint_text("accessKeyId"): {"redacted": True}, + } diff --git a/tests/agent/test_permission_audit_integration.py b/tests/agent/test_permission_audit_integration.py new file mode 100644 index 00000000..54359b6b --- /dev/null +++ b/tests/agent/test_permission_audit_integration.py @@ -0,0 +1,626 @@ +import json +from typing import Any +from unittest.mock import Mock + +import pytest + +from iac_code.agent.agent_loop import AgentLoop +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult +from iac_code.tools.bash.bash_tool import BashTool +from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi +from iac_code.tools.write_file import WriteFileTool +from iac_code.types.permissions import ( + PermissionAuditMetadata, + PermissionAuditSettings, + PermissionDecisionReason, + PermissionMode, + PermissionResult, + ToolPermissionContext, +) +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + PermissionRequestEvent, + TextDeltaEvent, + ToolResultEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + + +class FakeProvider: + """Mock provider that yields predetermined tool calls across multiple turns.""" + + def __init__(self, turns: list[list]): + self._turns = turns + self._call_count = 0 + + def get_model_name(self) -> str: + return "fake-model" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + idx = min(self._call_count, len(self._turns) - 1) + self._call_count += 1 + for event in self._turns[idx]: + yield event + + +class FakePermissionTool(Tool): + def __init__(self, permission: PermissionResult) -> None: + self.permission = permission + + @property + def name(self) -> str: + return "fake_permission" + + @property + def description(self) -> str: + return "Fake permission-controlled tool" + + @property + def input_schema(self) -> dict[str, Any]: + return {"type": "object", "properties": {"payload": {"type": "string"}}} + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + return ToolResult.success("executed") + + async def check_permissions(self, input: dict, context=None) -> PermissionResult: + return self.permission + + +class FakeAliyunApi(AliyunApi): + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + return ToolResult.success("{}") + + +def _read_jsonl(path): + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def _tool_turn( + tool_use_id: str = "t1", + *, + payload: str = "secret-value", + tool_name: str = "fake_permission", + tool_input: dict[str, Any] | None = None, +) -> list: + final_input = tool_input if tool_input is not None else {"payload": payload} + return [ + MessageStartEvent(message_id=f"msg-{tool_use_id}"), + ToolUseStartEvent(tool_use_id=tool_use_id, name=tool_name), + ToolUseEndEvent(tool_use_id=tool_use_id, name=tool_name, input=final_input), + MessageEndEvent(stop_reason="tool_use", usage=Usage()), + ] + + +def _text_turn(text: str) -> list: + return [ + MessageStartEvent(message_id="msg-text"), + TextDeltaEvent(text=text), + MessageEndEvent(stop_reason="end_turn", usage=Usage()), + ] + + +async def _collect_events(loop: AgentLoop, prompt: str, permission_handler=None): + events = [] + async for event in loop.run_streaming(prompt): + events.append(event) + if isinstance(event, PermissionRequestEvent) and event.response_future: + result = permission_handler(event) if permission_handler else False + event.response_future.set_result(result) + return events + + +def _audit_metadata( + *, + scope: str = "settings_rule", + rule_source: str | None = "user_settings", + rule: str | None = "fake_permission", + reason_type: str | None = "rule", + reason_detail: str | None = "matched fake rule", + is_read_only: bool | None = False, +) -> PermissionAuditMetadata: + return PermissionAuditMetadata( + scope=scope, + source="permission_pipeline", + rule_source=rule_source, + rule=rule, + reason_type=reason_type, + reason_detail=reason_detail, + is_read_only=is_read_only, + operation={"product": "ROS", "action": "CreateStack"}, + ) + + +async def _run_fake_tool_with_audit( + monkeypatch, + tmp_path, + permission: PermissionResult, + *, + context: ToolPermissionContext | None = None, +): + records = [] + settings_seen = [] + + def fake_emit(record, settings=None): + records.append(record) + settings_seen.append(settings) + + monkeypatch.setattr("iac_code.agent.agent_loop.emit_permission_audit", fake_emit) + provider = FakeProvider([_tool_turn(), _text_turn("done")]) + registry = ToolRegistry() + registry.register(FakePermissionTool(permission)) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-audit", + permission_context=context or ToolPermissionContext(cwd=str(tmp_path)), + ) + + events = await _collect_events(loop, "run fake tool", permission_handler=Mock(return_value=False)) + + return events, records, settings_seen + + +def _permission_requests(events) -> list[PermissionRequestEvent]: + return [event for event in events if isinstance(event, PermissionRequestEvent)] + + +@pytest.mark.asyncio +async def test_agent_loop_prompt_event_carries_internal_audit_context(tmp_path): + settings = PermissionAuditSettings(max_file_bytes=123, max_files=2) + metadata = _audit_metadata(scope="once", rule_source=None, rule=None, reason_type="needs_prompt") + provider = FakeProvider([_tool_turn(), _text_turn("done")]) + registry = ToolRegistry() + registry.register(FakePermissionTool(PermissionResult(behavior="ask", audit=metadata))) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-prompt", + permission_context=ToolPermissionContext(cwd=str(tmp_path), audit_settings=settings), + ) + + events = await _collect_events(loop, "run fake tool", permission_handler=Mock(return_value=False)) + + [prompt] = _permission_requests(events) + assert prompt.audit_context == { + "session_id": "session-prompt", + "settings": settings, + "metadata": metadata, + } + + +@pytest.mark.asyncio +async def test_agent_loop_bash_ask_rule_prompt_carries_rule_audit_context(tmp_path): + settings = PermissionAuditSettings(max_file_bytes=123, max_files=2) + provider = FakeProvider([_tool_turn(tool_name="bash", tool_input={"command": "mkdir foo"}), _text_turn("done")]) + registry = ToolRegistry() + registry.register(BashTool()) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-bash-ask", + permission_context=ToolPermissionContext( + cwd=str(tmp_path), + ask_rules={"project_settings": ["bash(mkdir:*)"]}, + audit_settings=settings, + ), + ) + + events = await _collect_events(loop, "run bash", permission_handler=Mock(return_value=False)) + + [prompt] = _permission_requests(events) + metadata = prompt.audit_context["metadata"] + assert prompt.audit_context["session_id"] == "session-bash-ask" + assert prompt.audit_context["settings"] is settings + assert metadata.scope == "settings_rule" + assert metadata.rule_source == "project_settings" + assert metadata.rule == "bash(mkdir:*)" + assert metadata.reason_type == "rule" + assert metadata.is_read_only is False + + +@pytest.mark.asyncio +async def test_agent_loop_bash_accept_edits_allow_is_audited(monkeypatch, tmp_path): + records = [] + settings_seen = [] + settings = PermissionAuditSettings(max_file_bytes=123, max_files=2) + + def fake_emit(record, settings=None): + records.append(record) + settings_seen.append(settings) + + monkeypatch.setattr("iac_code.agent.agent_loop.emit_permission_audit", fake_emit) + provider = FakeProvider([_tool_turn(tool_name="bash", tool_input={"command": "mkdir foo"}), _text_turn("done")]) + registry = ToolRegistry() + registry.register(BashTool()) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-accept-edits", + permission_context=ToolPermissionContext( + cwd=str(tmp_path), + mode=PermissionMode.ACCEPT_EDITS, + audit_settings=settings, + ), + ) + + events = await _collect_events(loop, "run bash", permission_handler=Mock(return_value=False)) + + assert not _permission_requests(events) + assert len(records) == 1 + assert records[0].decision == "allow" + assert records[0].scope == "mode" + assert records[0].source == "permission_pipeline" + assert records[0].rule_source == "mode" + assert records[0].reason_type == "accept_edits" + assert records[0].operation == {"is_read_only": False} + assert settings_seen == [settings] + + +@pytest.mark.asyncio +async def test_agent_loop_aliyun_exact_allow_jsonl_includes_read_write_classification(monkeypatch, tmp_path): + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + provider = FakeProvider( + [ + _tool_turn( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack", "region_id": "cn-hangzhou"}, + ), + _text_turn("done"), + ] + ) + registry = ToolRegistry() + registry.register(FakeAliyunApi()) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-aliyun-allow", + permission_context=ToolPermissionContext( + cwd=str(tmp_path), + allow_rules={"user_settings": ["aliyun_api(ros:CreateStack)"]}, + ), + ) + + events = await _collect_events(loop, "run aliyun", permission_handler=Mock(return_value=False)) + + assert not _permission_requests(events) + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert row["tool_name"] == "aliyun_api" + assert row["decision"] == "allow" + assert row["scope"] == "settings_rule" + assert row["operation"] == { + "product": "ros", + "action": "CreateStack", + "region": "cn-hangzhou", + "is_read_only": False, + } + + +@pytest.mark.asyncio +async def test_agent_loop_aliyun_bypass_mode_allows_write_with_audit(monkeypatch, tmp_path): + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + provider = FakeProvider( + [ + _tool_turn( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack", "region_id": "cn-hangzhou"}, + ), + _text_turn("done"), + ] + ) + registry = ToolRegistry() + registry.register(FakeAliyunApi()) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-aliyun-bypass", + permission_context=ToolPermissionContext(cwd=str(tmp_path), mode=PermissionMode.BYPASS_PERMISSIONS), + ) + + events = await _collect_events(loop, "run aliyun", permission_handler=Mock(return_value=False)) + + assert not _permission_requests(events) + assert any(isinstance(event, ToolResultEvent) and not event.is_error for event in events) + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert row["tool_name"] == "aliyun_api" + assert row["decision"] == "allow" + assert row["scope"] == "mode" + assert row["source"] == "permission_pipeline" + assert row["rule_source"] == "mode" + assert row["reason_type"] == "bypass_permissions" + assert row["operation"] == { + "product": "ros", + "action": "CreateStack", + "region": "cn-hangzhou", + "is_read_only": False, + } + + +@pytest.mark.asyncio +async def test_agent_loop_aliyun_bypass_mode_denies_write_when_audit_fails(monkeypatch, tmp_path): + provider = FakeProvider( + [ + _tool_turn( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack", "region_id": "cn-hangzhou"}, + ), + _text_turn("done"), + ] + ) + registry = ToolRegistry() + registry.register(FakeAliyunApi()) + monkeypatch.setattr("iac_code.agent.agent_loop.emit_permission_audit", lambda record, settings=None: False) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-aliyun-bypass-audit-fail", + permission_context=ToolPermissionContext(cwd=str(tmp_path), mode=PermissionMode.BYPASS_PERMISSIONS), + ) + + events = await _collect_events(loop, "run aliyun", permission_handler=Mock(return_value=True)) + + assert not _permission_requests(events) + assert any( + isinstance(event, ToolResultEvent) and event.is_error and event.result == "Permission denied." + for event in events + ) + + +@pytest.mark.asyncio +async def test_agent_loop_sticky_prompt_carries_trigger_reason_metadata(tmp_path): + outside_path = tmp_path.parent / "outside-workspace.txt" + provider = FakeProvider( + [ + _tool_turn( + tool_name="write_file", + tool_input={"path": str(outside_path), "content": "secret-value"}, + ), + _text_turn("done"), + ] + ) + registry = ToolRegistry() + registry.register(WriteFileTool()) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-path-constraint", + permission_context=ToolPermissionContext(cwd=str(tmp_path)), + ) + + events = await _collect_events(loop, "write outside", permission_handler=Mock(return_value=False)) + + [prompt] = _permission_requests(events) + metadata = prompt.audit_context["metadata"] + assert metadata is not None + assert metadata.scope == "once" + assert metadata.source == "permission_pipeline" + assert metadata.reason_type == "path_constraint" + assert metadata.reason_detail == "path_constraint" + assert metadata.is_read_only is False + + +@pytest.mark.asyncio +async def test_agent_loop_bash_compound_prompt_carries_trigger_reason_metadata(tmp_path): + provider = FakeProvider( + [ + _tool_turn(tool_name="bash", tool_input={"command": "cd repo && git status"}), + _text_turn("done"), + ] + ) + registry = ToolRegistry() + registry.register(BashTool()) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-compound-cd-git", + permission_context=ToolPermissionContext(cwd=str(tmp_path)), + ) + + events = await _collect_events(loop, "run bash", permission_handler=Mock(return_value=False)) + + [prompt] = _permission_requests(events) + metadata = prompt.audit_context["metadata"] + assert metadata is not None + assert metadata.scope == "once" + assert metadata.source == "permission_pipeline" + assert metadata.reason_type == "compound_cd_git" + assert metadata.reason_detail == "compound_cd_git" + assert metadata.is_read_only is False + + +@pytest.mark.asyncio +async def test_agent_loop_audits_no_prompt_allow_with_audit_metadata(monkeypatch, tmp_path): + events, records, settings_seen = await _run_fake_tool_with_audit( + monkeypatch, + tmp_path, + PermissionResult(behavior="allow", audit=_audit_metadata(rule_source="user_settings")), + ) + + assert not _permission_requests(events) + assert len(records) == 1 + assert records[0].decision == "allow" + assert records[0].scope == "settings_rule" + assert records[0].source == "permission_pipeline" + assert records[0].rule_source == "user_settings" + assert records[0].tool_name == "fake_permission" + assert records[0].tool_use_id == "t1" + assert records[0].operation == {"product": "ROS", "action": "CreateStack", "is_read_only": False} + assert records[0].input_summary["fields"]["payload"] == {"type": "str"} + assert "secret-value" not in str(records[0].input_summary) + assert settings_seen[0] is not None + + +@pytest.mark.asyncio +async def test_agent_loop_no_prompt_allow_denies_when_audit_fails(monkeypatch, tmp_path): + monkeypatch.setattr("iac_code.agent.agent_loop.emit_permission_audit", lambda record, settings=None: False) + provider = FakeProvider([_tool_turn(), _text_turn("done")]) + registry = ToolRegistry() + registry.register(FakePermissionTool(PermissionResult(behavior="allow", audit=_audit_metadata()))) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-audit-fail", + permission_context=ToolPermissionContext(cwd=str(tmp_path)), + ) + + events = await _collect_events(loop, "run fake tool", permission_handler=Mock(return_value=True)) + + assert not _permission_requests(events) + assert any( + isinstance(event, ToolResultEvent) and event.is_error and event.result == "Permission denied." + for event in events + ) + + +@pytest.mark.asyncio +async def test_agent_loop_no_prompt_audit_populates_redacted_input_when_enabled(monkeypatch, tmp_path): + records = [] + settings_seen = [] + settings = PermissionAuditSettings(include_tool_input=True, max_file_bytes=123, max_files=2) + + def fake_emit(record, settings=None): + records.append(record) + settings_seen.append(settings) + + monkeypatch.setattr("iac_code.agent.agent_loop.emit_permission_audit", fake_emit) + provider = FakeProvider( + [ + _tool_turn(tool_input={"payload": "visible", "access_key_secret": "secret-value"}), + _text_turn("done"), + ] + ) + registry = ToolRegistry() + registry.register(FakePermissionTool(PermissionResult(behavior="allow", audit=_audit_metadata()))) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="session-audit", + permission_context=ToolPermissionContext(cwd=str(tmp_path), audit_settings=settings), + ) + + events = await _collect_events(loop, "run fake tool", permission_handler=Mock(return_value=False)) + + assert not _permission_requests(events) + assert len(records) == 1 + assert records[0].tool_input_redacted == { + "payload": {"type": "str", "length": 7, "fingerprint": fingerprint_text("visible")}, + fingerprint_text("access_key_secret"): {"redacted": True}, + } + assert settings_seen == [settings] + + +@pytest.mark.asyncio +async def test_agent_loop_audits_no_prompt_deny_with_audit_metadata(monkeypatch, tmp_path): + events, records, _settings_seen = await _run_fake_tool_with_audit( + monkeypatch, + tmp_path, + PermissionResult(behavior="deny", audit=_audit_metadata(rule_source="project_settings")), + ) + + assert not _permission_requests(events) + assert any(isinstance(event, ToolResultEvent) and event.is_error for event in events) + assert len(records) == 1 + assert records[0].decision == "deny" + assert records[0].scope == "settings_rule" + assert records[0].rule_source == "project_settings" + + +@pytest.mark.asyncio +async def test_agent_loop_audits_dont_ask_mode_through_permission_pipeline(monkeypatch, tmp_path): + events, records, _settings_seen = await _run_fake_tool_with_audit( + monkeypatch, + tmp_path, + PermissionResult(behavior="ask", audit=_audit_metadata(scope="once", rule_source=None, rule=None)), + context=ToolPermissionContext(cwd=str(tmp_path), mode=PermissionMode.DONT_ASK), + ) + + assert not _permission_requests(events) + assert len(records) == 1 + assert records[0].decision == "deny" + assert records[0].scope == "mode" + assert records[0].rule_source == "mode" + assert records[0].reason_type == "dont_ask" + + +@pytest.mark.asyncio +async def test_agent_loop_does_not_audit_read_only_no_prompt_allow(monkeypatch, tmp_path): + events, records, _settings_seen = await _run_fake_tool_with_audit( + monkeypatch, + tmp_path, + PermissionResult(behavior="allow", audit=_audit_metadata(scope="read_only", is_read_only=True)), + ) + + assert not _permission_requests(events) + assert records == [] + + +@pytest.mark.asyncio +async def test_agent_loop_audits_read_only_no_prompt_deny(monkeypatch, tmp_path): + events, records, _settings_seen = await _run_fake_tool_with_audit( + monkeypatch, + tmp_path, + PermissionResult( + behavior="deny", + message="blocked", + audit=_audit_metadata(scope="settings_rule", rule_source="project_settings", is_read_only=True), + ), + ) + + assert not _permission_requests(events) + assert any(isinstance(event, ToolResultEvent) and event.is_error for event in events) + assert len(records) == 1 + assert records[0].decision == "deny" + assert records[0].scope == "settings_rule" + assert records[0].rule_source == "project_settings" + + +@pytest.mark.asyncio +async def test_agent_loop_does_not_audit_no_prompt_decision_without_audit_metadata(monkeypatch, tmp_path): + events, records, _settings_seen = await _run_fake_tool_with_audit( + monkeypatch, + tmp_path, + PermissionResult( + behavior="allow", + reason=PermissionDecisionReason(type="rule", detail="matched allow rule: fake_permission"), + ), + context=ToolPermissionContext(cwd=str(tmp_path), allow_rules={"user_settings": ["fake_permission"]}), + ) + + assert not _permission_requests(events) + assert records == [] diff --git a/tests/cli/test_headless_permission_audit.py b/tests/cli/test_headless_permission_audit.py new file mode 100644 index 00000000..40f8f517 --- /dev/null +++ b/tests/cli/test_headless_permission_audit.py @@ -0,0 +1,302 @@ +"""Permission audit coverage for headless execution boundaries.""" + +from __future__ import annotations + +import asyncio +import io +from unittest.mock import AsyncMock, patch + +import pytest + +from iac_code.cli.headless import EXIT_OK, HeadlessRunner +from iac_code.cli.output_formats import OutputFormat +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.services.telemetry.names import Events +from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings +from iac_code.types.stream_events import MessageEndEvent, PermissionRequestEvent, SubPipelineStreamEvent, Usage + + +def _make_runner( + output_format: OutputFormat = OutputFormat.TEXT, + output_stream: io.StringIO | None = None, +) -> HeadlessRunner: + return HeadlessRunner( + model="test-model", + output_format=output_format, + output_stream=output_stream or io.StringIO(), + ) + + +async def _fake_stream(*events): + """Create an async generator that yields the given events.""" + for event in events: + yield event + + +@pytest.mark.asyncio +async def test_headless_auto_approval_uses_general_permission_event(monkeypatch: pytest.MonkeyPatch) -> None: + buf = io.StringIO() + runner = _make_runner(OutputFormat.TEXT, buf) + audit_records = [] + telemetry_events = [] + + def fake_emit_permission_audit(record, settings=None): + audit_records.append((record, settings)) + + def fake_log_event(event_name, metadata): + telemetry_events.append((event_name, metadata)) + + monkeypatch.setattr("iac_code.services.permissions.audit.emit_permission_audit", fake_emit_permission_audit) + monkeypatch.setattr("iac_code.cli.headless.log_event", fake_log_event, raising=False) + monkeypatch.setattr("iac_code.services.telemetry.log_event", fake_log_event) + monkeypatch.setattr("iac_code.cli.headless.graceful_shutdown", lambda: None) + + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + events = [ + PermissionRequestEvent( + tool_name="bash", + tool_input={"command": "ls", "apiKey": "secret-value"}, + tool_use_id="tu_1", + response_future=future, + audit_context={ + "session_id": "session-headless", + "settings": PermissionAuditSettings(include_tool_input=True, max_file_bytes=123, max_files=2), + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + rule="bash(ls)", + reason_type="rule", + reason_detail="matched ask rule: bash(ls)", + is_read_only=False, + operation={"is_read_only": False}, + ), + }, + ), + MessageEndEvent(stop_reason="end_turn", usage=Usage()), + ] + + with patch.object(runner, "_create_agent_loop") as mock_create: + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_create.return_value = mock_loop + + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert future.result() is True + assert len(audit_records) == 1 + record, settings_seen = audit_records[0] + assert settings_seen is events[0].audit_context["settings"] + assert record.session_id == "session-headless" + assert record.source == "headless_auto_approve" + assert record.scope == "auto_approve" + assert record.decision == "allow" + assert record.rule_source == "user_settings" + assert record.rule == "bash(ls)" + assert record.operation == {"is_read_only": False} + assert record.tool_input_redacted == { + "command": {"type": "str", "length": 2, "fingerprint": fingerprint_text("ls")}, + fingerprint_text("apiKey"): {"redacted": True}, + } + assert all(event_name != Events.TOOL_USE_GRANTED_IN_PROMPT for event_name, _metadata in telemetry_events) + + +@pytest.mark.asyncio +async def test_headless_auto_approval_denies_when_audit_log_write_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + def fail_append(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fail_append) + monkeypatch.setattr("iac_code.cli.headless.graceful_shutdown", lambda: None) + runner = _make_runner(OutputFormat.TEXT, io.StringIO()) + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + events = [ + PermissionRequestEvent( + tool_name="bash", + tool_input={"command": "mkdir out"}, + tool_use_id="tu-audit-fail", + response_future=future, + audit_context={ + "session_id": "session-headless-audit-fail", + "metadata": PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + rule="bash(mkdir:*)", + reason_type="rule", + reason_detail="matched allow rule: bash(mkdir:*)", + is_read_only=False, + operation={"is_read_only": False}, + ), + }, + ), + MessageEndEvent(stop_reason="end_turn", usage=Usage()), + ] + + with patch.object(runner, "_create_agent_loop") as mock_create: + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_create.return_value = mock_loop + + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert future.result() is False + + +@pytest.mark.asyncio +async def test_headless_auto_approval_denies_untrusted_aliyun_write(monkeypatch: pytest.MonkeyPatch) -> None: + runner = _make_runner(OutputFormat.TEXT, io.StringIO()) + audit_records = [] + + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + monkeypatch.setattr("iac_code.cli.headless.graceful_shutdown", lambda: None) + + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + events = [ + PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tu-aliyun-write", + response_future=future, + audit_context={ + "session_id": "session-headless", + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ), + }, + ), + MessageEndEvent(stop_reason="end_turn", usage=Usage()), + ] + + with patch.object(runner, "_create_agent_loop") as mock_create: + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_create.return_value = mock_loop + + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert future.result() is False + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "headless_auto_deny" + assert record.scope == "auto_deny" + assert record.decision == "deny" + assert record.reason_type == "untrusted_write" + assert record.operation == {"product": "ros", "action": "CreateStack", "is_read_only": False} + + +@pytest.mark.asyncio +async def test_headless_auto_approval_handles_wrapped_aliyun_write(monkeypatch: pytest.MonkeyPatch) -> None: + runner = _make_runner(OutputFormat.TEXT, io.StringIO()) + audit_records = [] + + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + monkeypatch.setattr("iac_code.cli.headless.graceful_shutdown", lambda: None) + + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + permission_event = PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + tool_use_id="tu-wrapped-aliyun-write", + response_future=future, + audit_context={ + "session_id": "session-headless", + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + reason_detail="untrusted Aliyun write", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ), + }, + ) + events = [ + SubPipelineStreamEvent( + sub_pipeline_id="sub-1", + candidate_index=0, + inner=permission_event, + ), + MessageEndEvent(stop_reason="end_turn", usage=Usage()), + ] + + with patch.object(runner, "_create_agent_loop") as mock_create: + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_create.return_value = mock_loop + + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert future.result() is False + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "headless_auto_deny" + assert record.scope == "auto_deny" + assert record.decision == "deny" + assert record.reason_type == "untrusted_write" + + +@pytest.mark.asyncio +async def test_headless_completed_permission_future_is_not_audited(monkeypatch: pytest.MonkeyPatch) -> None: + buf = io.StringIO() + runner = _make_runner(OutputFormat.TEXT, buf) + audit_records = [] + telemetry_events = [] + + def fake_log_event(event_name, metadata): + telemetry_events.append((event_name, metadata)) + + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + ) + monkeypatch.setattr("iac_code.cli.headless.log_event", fake_log_event) + monkeypatch.setattr("iac_code.cli.headless.graceful_shutdown", lambda: None) + + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + future.set_result(False) + events = [ + PermissionRequestEvent( + tool_name="bash", + tool_input={"command": "ls"}, + tool_use_id="tu_done", + response_future=future, + ), + MessageEndEvent(stop_reason="end_turn", usage=Usage()), + ] + + with patch.object(runner, "_create_agent_loop") as mock_create: + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_create.return_value = mock_loop + + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert future.result() is False + assert audit_records == [] + assert all(event_name != Events.TOOL_USE_GRANTED_IN_PROMPT for event_name, _metadata in telemetry_events) diff --git a/tests/cli/test_output_formats.py b/tests/cli/test_output_formats.py index 965fe153..8837bb48 100644 --- a/tests/cli/test_output_formats.py +++ b/tests/cli/test_output_formats.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import io import json @@ -12,11 +13,17 @@ TextWriter, create_writer, ) +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings, PermissionResult from iac_code.types.stream_events import ( ErrorEvent, MessageEndEvent, MessageStartEvent, + PermissionRequestEvent, + SubAgentToolEvent, + SubPipelineStreamEvent, TextDeltaEvent, + ToolInputDeltaEvent, ToolResultEvent, ToolUseEndEvent, ToolUseStartEvent, @@ -83,7 +90,8 @@ def test_collects_text_and_tool_results(self) -> None: assert len(result["tool_uses"]) == 1 tool = result["tool_uses"][0] assert tool["name"] == "bash" - assert tool["input"] == {"cmd": "ls"} + assert tool["input_summary"] == {"tool_name": "bash", "fields": {"cmd": {"type": "str"}}} + assert "input" not in tool assert tool["result"] == "file.txt" assert tool["is_error"] is False assert result["usage"] == { @@ -219,14 +227,31 @@ def test_tool_events_emitted(self) -> None: stream = io.StringIO() writer = StreamJsonWriter(stream) writer.handle(ToolUseStartEvent(tool_use_id="tu_1", name="bash")) + writer.handle(ToolInputDeltaEvent(tool_use_id="tu_1", partial_json='ature":"signature-secret"')) + writer.handle( + ToolUseEndEvent( + tool_use_id="tu_1", + name="aliyun_api", + input={"product": "ros", "action": "CreateStack", "params": {"Signature": "signature-secret"}}, + ) + ) writer.handle(ToolResultEvent(tool_use_id="tu_1", tool_name="bash", result="done")) lines = stream.getvalue().strip().splitlines() - assert len(lines) == 2 + assert len(lines) == 4 first = json.loads(lines[0]) - second = json.loads(lines[1]) + delta = json.loads(lines[1]) + end = json.loads(lines[2]) + second = json.loads(lines[3]) assert first["type"] == "tool_use_start" + assert delta["type"] == "tool_input_delta" + assert delta["partial_json_length"] == len('ature":"signature-secret"') + assert "partial_json" not in delta + assert end["type"] == "tool_use_end" + assert "input" not in end + assert end["input_summary"]["tool_name"] == "aliyun_api" assert second["type"] == "tool_result" + assert "signature-secret" not in stream.getvalue() def test_tool_result_omits_null_metadata_for_field_stability(self) -> None: stream = io.StringIO() @@ -237,6 +262,102 @@ def test_tool_result_omits_null_metadata_for_field_stability(self) -> None: assert data["type"] == "tool_result" assert "metadata" not in data + def test_subagent_tool_event_omits_raw_child_input(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + + writer.handle( + SubAgentToolEvent( + parent_tool_use_id="parent", + child_tool_name="aliyun_api", + child_tool_input={ + "product": "ROS", + "action": "CreateStack", + "params": {"Signature": "signature-secret", "AccessKeySecret": "secret-value"}, + "headers": {"Authorization": "Bearer bearer-secret"}, + }, + ) + ) + + data = json.loads(stream.getvalue()) + rendered = stream.getvalue() + assert data["type"] == "subagent_tool" + assert data["child_tool_name"] == "aliyun_api" + assert data["child_input_summary"]["tool_name"] == "aliyun_api" + assert "child_tool_input" not in data + assert "signature-secret" not in rendered + assert "secret-value" not in rendered + assert "bearer-secret" not in rendered + + def test_sub_pipeline_tool_use_omits_raw_inner_input(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + + writer.handle( + SubPipelineStreamEvent( + sub_pipeline_id="sub-1", + candidate_index=0, + inner=ToolUseEndEvent( + tool_use_id="tu_aliyun", + name="aliyun_api", + input={ + "product": "ros", + "action": "CreateStack", + "params": {"AccessKeySecret": "secret-value", "StackName": "demo"}, + }, + ), + ) + ) + + data = json.loads(stream.getvalue()) + rendered = json.dumps(data, ensure_ascii=False) + assert data["type"] == "sub_pipeline_stream" + assert data["inner"]["type"] == "tool_use_end" + assert "input" not in data["inner"] + assert data["inner"]["input_summary"]["tool_name"] == "aliyun_api" + assert data["inner"]["input_summary"]["params_fields"] == sorted( + [fingerprint_text("StackName"), fingerprint_text("AccessKeySecret")] + ) + assert data["inner"]["input_summary"]["params_field_count"] == 2 + assert "AccessKeySecret" not in rendered + assert "StackName" not in rendered + assert "secret-value" not in rendered + + def test_sub_pipeline_permission_request_omits_future_and_uses_summary(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + loop = asyncio.new_event_loop() + try: + writer.handle( + SubPipelineStreamEvent( + sub_pipeline_id="sub-1", + candidate_index=0, + inner=PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={ + "product": "ros", + "action": "CreateStack", + "params": {"AccessKeySecret": "secret-value", "StackName": "demo"}, + }, + tool_use_id="tu_aliyun", + response_future=loop.create_future(), + ), + ) + ) + finally: + loop.close() + + data = json.loads(stream.getvalue()) + rendered = json.dumps(data, ensure_ascii=False) + assert data["type"] == "sub_pipeline_stream" + assert data["inner"]["type"] == "permission_request" + assert data["inner"]["tool_name"] == "aliyun_api" + assert data["inner"]["input_summary"]["tool_name"] == "aliyun_api" + assert "tool_input" not in data["inner"] + assert "response_future" not in data["inner"] + assert "AccessKeySecret" not in rendered + assert "secret-value" not in rendered + def test_tool_result_preserves_non_null_metadata(self) -> None: stream = io.StringIO() writer = StreamJsonWriter(stream) @@ -334,6 +455,108 @@ def test_error_event_preserves_error_id(self) -> None: assert data["type"] == "error" assert data["error_id"] == "err-abc123" + def test_permission_request_omits_internal_audit_context(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + loop = asyncio.new_event_loop() + try: + writer.handle( + PermissionRequestEvent( + tool_name="write_file", + tool_input={ + "path": "x", + "content": "secret", + "customerEmail": "alice@example.com", + "customer-prod-123": "tenant-id", + }, + tool_use_id="tu_1", + response_future=loop.create_future(), + permission_result=PermissionResult( + behavior="ask", + audit=PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + operation={"action": "DeleteStack"}, + ), + ), + audit_context={ + "session_id": "session-secret", + "settings": PermissionAuditSettings(max_file_bytes=123), + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + operation={"action": "CreateStack"}, + ), + }, + ) + ) + finally: + loop.close() + + data = json.loads(stream.getvalue()) + rendered = json.dumps(data, ensure_ascii=False) + assert data["type"] == "permission_request" + assert data["tool_name"] == "write_file" + assert data["tool_use_id"] == "tu_1" + assert data["input_summary"] == { + "tool_name": "write_file", + "fields": { + "path": {"type": "str"}, + "content": {"type": "str"}, + fingerprint_text("customerEmail"): {"type": "str"}, + fingerprint_text("customer-prod-123"): {"type": "str"}, + }, + } + assert "tool_input" not in data + assert "response_future" not in data + assert "permission_result" not in data + assert "audit_context" not in data + assert "secret" not in rendered + assert "session-secret" not in rendered + assert "customerEmail" not in rendered + assert "customer-prod-123" not in rendered + assert "max_file_bytes" not in rendered + assert "CreateStack" not in rendered + assert "DeleteStack" not in rendered + + def test_permission_request_emits_safe_input_summary(self) -> None: + stream = io.StringIO() + writer = StreamJsonWriter(stream) + + writer.handle( + PermissionRequestEvent( + tool_name="aliyun_api", + tool_input={ + "product": "ros", + "action": "CreateStack", + "params": { + "StackName": "demo", + "AccessKeySecret": "secret-value", + "Signature": "signature-secret", + }, + "body": {"PrivateKey": "private-secret"}, + }, + tool_use_id="tu_aliyun", + ) + ) + + data = json.loads(stream.getvalue()) + rendered = json.dumps(data, ensure_ascii=False) + assert "tool_input" not in data + assert data["input_summary"]["tool_name"] == "aliyun_api" + assert data["input_summary"]["product"] == "ros" + assert data["input_summary"]["action"] == "CreateStack" + assert "params_fields" in data["input_summary"] + for forbidden in ( + "secret-value", + "signature-secret", + "private-secret", + "AccessKeySecret", + "Signature", + "PrivateKey", + ): + assert forbidden not in rendered + def test_failed_tool_result_is_sanitized(self) -> None: stream = io.StringIO() writer = StreamJsonWriter(stream) diff --git a/tests/services/permissions/test_audit.py b/tests/services/permissions/test_audit.py new file mode 100644 index 00000000..10a3bd90 --- /dev/null +++ b/tests/services/permissions/test_audit.py @@ -0,0 +1,1022 @@ +import json +import os +import stat +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from iac_code.services.permissions.audit import ( + PermissionAuditRecord, + build_display_tool_input, + build_input_summary, + build_prompt_tool_input, + build_redacted_tool_input, + emit_permission_audit, + emit_permission_boundary_audit, + fingerprint_text, + is_permission_audit_non_read_only, + sanitize_free_text, +) +from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings + + +def _read_jsonl(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def _has_truncated_object(value: object) -> bool: + if isinstance(value, dict): + if value.get("type") == "object" and value.get("truncated") is True: + return True + return any(_has_truncated_object(child) for child in value.values()) + return False + + +def test_fingerprint_is_stable_and_short() -> None: + assert fingerprint_text("secret-value") == fingerprint_text("secret-value") + assert fingerprint_text("secret-value").startswith("sha256:") + + +def test_generic_summary_excludes_raw_values() -> None: + summary = build_input_summary("bash", {"command": "rm /secret", "nested": {"token": "abc"}}) + assert summary["tool_name"] == "bash" + assert "command" in summary["fields"] + assert "rm /secret" not in json.dumps(summary) + assert "abc" not in json.dumps(summary) + + +def test_generic_summary_sanitizes_arbitrary_field_names() -> None: + summary = build_input_summary( + "bash", + { + "token=secret-token": "value", + "nested": {"apiKey=api-secret": "value"}, + }, + ) + serialized = json.dumps(summary) + assert "token=secret-token" not in serialized + assert "apiKey=api-secret" not in serialized + assert fingerprint_text("token=secret-token") in serialized + assert fingerprint_text("apiKey=api-secret") in serialized + + +def test_generic_summary_fingerprints_business_field_names() -> None: + summary = build_input_summary( + "bash", + { + "command": "git status", + "customerEmail": "alice@example.com", + "customer-prod-123": "tenant-id", + }, + ) + + serialized = json.dumps(summary) + assert "command" in summary["fields"] + assert "customerEmail" not in serialized + assert "customer-prod-123" not in serialized + assert fingerprint_text("customerEmail") in summary["fields"] + assert fingerprint_text("customer-prod-123") in summary["fields"] + + +def test_aliyun_summary_fingerprints_unsafe_values() -> None: + summary = build_input_summary( + "aliyun_api", + { + "product": "ros*", + "action": "CreateStack", + "region_id": "cn-hangzhou", + "style": "ROA", + "method": "POST", + "pathname": "/clusters/c-secret-123/nodes", + "params": {"StackName": "prod", "token=secret-token": "value"}, + }, + ) + serialized = json.dumps(summary) + assert "c-secret-123" not in serialized + assert "StackName" not in serialized + assert fingerprint_text("StackName") in serialized + assert "token=secret-token" not in serialized + assert fingerprint_text("token=secret-token") in serialized + assert "product_fingerprint" in serialized + assert "api_fingerprint" not in serialized + assert "ros*" not in serialized + + +def test_input_summary_truncates_deep_and_wide_shapes() -> None: + nested: object = "leaf" + for _ in range(100): + nested = {"next": nested} + wide = {f"field_{index}": index for index in range(100)} + + summary = build_input_summary("bash", {"nested": nested, "wide": wide}) + + nested_key = fingerprint_text("nested") + wide_key = fingerprint_text("wide") + assert _has_truncated_object(summary["fields"][nested_key]) + assert summary["fields"][wide_key]["_truncated"] == {"type": "object", "truncated": True} + assert "field_99" not in summary["fields"][wide_key]["fields"] + + +def test_aliyun_summary_uses_field_fingerprints_with_width_limit() -> None: + params = {f"StackName{index}": index for index in range(100)} + + summary = build_input_summary("aliyun_api", {"params": params}) + + assert summary["params_field_count"] == 100 + assert summary["params_fields_truncated"] is True + assert len(summary["params_fields"]) == 64 + serialized = json.dumps(summary) + assert "StackName0" not in serialized + assert fingerprint_text("StackName0") in serialized + assert "StackName99" not in serialized + + +def test_emit_permission_audit_preserves_field_shape_fingerprint_keys( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + record = PermissionAuditRecord( + session_id="s-field-shapes", + tool_name="aliyun_api", + tool_use_id="tu-field-shapes", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + input_summary=build_input_summary("aliyun_api", {"params": {"StackName": "demo"}}), + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + field_key = fingerprint_text("StackName") + assert row["input_summary"]["params_fields"] == [field_key] + assert field_key in row["input_summary"]["params_field_shapes"] + + +def test_emit_permission_audit_sanitizes_raw_field_shape_keys( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + record = PermissionAuditRecord( + session_id="s-field-shapes", + tool_name="aliyun_api", + tool_use_id="tu-field-shapes", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + input_summary={ + "tool_name": "aliyun_api", + "params_fields": ["StackName"], + "params_field_shapes": {"StackName": {"type": "str"}}, + }, + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + field_key = fingerprint_text("StackName") + serialized = json.dumps(row) + assert "StackName" not in serialized + assert row["input_summary"]["params_fields"] == [field_key] + assert field_key in row["input_summary"]["params_field_shapes"] + + +def test_build_display_tool_input_truncates_deep_wide_and_cyclic_values() -> None: + nested: object = "leaf" + for _ in range(100): + nested = {"next": nested} + wide = {f"field_{index}": index for index in range(100)} + cyclic: dict[str, object] = {} + cyclic["self"] = cyclic + + display = build_display_tool_input({"path": "main.tf", "nested": nested, "wide": wide, "cyclic": cyclic}) + + assert display["path"] == "main.tf" + assert _has_truncated_object(display["nested"]) + assert display["wide"]["_truncated"] == {"type": "object", "truncated": True} + assert "field_99" not in display["wide"] + assert _has_truncated_object(display["cyclic"]) + + +def test_build_display_tool_input_preserves_priority_fields_in_wide_objects() -> None: + tool_input = {f"field_{index}": index for index in range(100)} + tool_input["command"] = "rm -rf /" + + display = build_display_tool_input(tool_input) + + assert display["command"] == "rm -rf /" + assert display["_truncated"] == {"type": "object", "truncated": True} + assert "field_99" not in display + + +def test_build_display_tool_input_marks_long_strings_with_suffix() -> None: + command = ("echo safe && " * 40) + "rm -rf /" + + display = build_display_tool_input({"command": command}) + + assert display["command"]["type"] == "str" + assert display["command"]["length"] == len(command) + assert display["command"]["truncated"] is True + assert "prefix" in display["command"] + assert "rm -rf /" in display["command"]["suffix"] + + +def test_build_prompt_tool_input_redacts_space_separated_secret_flags_and_preserves_paths() -> None: + command = "cat /Users/alice/project/main.tf --token abc123value --password 'hunter2' --api-key sk-live-secret" + + prompt = build_prompt_tool_input({"command": command, "path": "/Users/alice/project/main.tf"}) + + rendered = json.dumps(prompt, ensure_ascii=False) + assert "/Users/alice/project/main.tf" in rendered + assert "--token [REDACTED]" in rendered + assert "--password [REDACTED]" in rendered + assert "--api-key [REDACTED]" in rendered + assert "abc123value" not in rendered + assert "hunter2" not in rendered + assert "sk-live-secret" not in rendered + assert "[PATH]" not in rendered + + +def test_build_prompt_tool_input_redacts_env_secret_assignments_without_false_flag_matches() -> None: + command = ( + "OPENAI_API_KEY=sk-openai AWS_SECRET_ACCESS_KEY='aws-secret' " + "ALIBABA_CLOUD_ACCESS_KEY_SECRET=aliyun-secret echo my-secret value /Users/alice/project/main.tf" + ) + + prompt = build_prompt_tool_input({"command": command, "path": "/Users/alice/project/main.tf"}) + + rendered = json.dumps(prompt, ensure_ascii=False) + assert "OPENAI_API_KEY=[REDACTED]" in rendered + assert "AWS_SECRET_ACCESS_KEY=[REDACTED]" in rendered + assert "ALIBABA_CLOUD_ACCESS_KEY_SECRET=[REDACTED]" in rendered + assert "my-secret value" in rendered + assert "/Users/alice/project/main.tf" in rendered + for forbidden in ("sk-openai", "aws-secret", "aliyun-secret", "[PATH]"): + assert forbidden not in rendered + + +def test_build_prompt_tool_input_redacts_long_secret_assignments_before_truncating() -> None: + secret = "sk-" + ("x" * 260) + "tail-secret" + command = f"OPENAI_API_KEY={secret} echo done /Users/alice/project/main.tf" + + prompt = build_prompt_tool_input({"command": command, "path": "/Users/alice/project/main.tf"}) + + rendered = json.dumps(prompt, ensure_ascii=False) + assert "OPENAI_API_KEY=[REDACTED]" in rendered + assert "tail-secret" not in rendered + assert secret not in rendered + assert "/Users/alice/project/main.tf" in rendered + assert "[PATH]" not in rendered + + +def test_build_prompt_tool_input_redacts_json_and_escaped_quote_secret_values() -> None: + command = ( + 'curl -d \'{"apiKey":"sk-json-secret"}\' ' + 'OPENAI_API_KEY="abc\\"escaped-tail-secret" ' + "/Users/alice/project/main.tf" + ) + + prompt = build_prompt_tool_input({"command": command, "path": "/Users/alice/project/main.tf"}) + + rendered = json.dumps(prompt, ensure_ascii=False) + assert "apiKey" in rendered + assert "OPENAI_API_KEY=[REDACTED]" in rendered + assert "/Users/alice/project/main.tf" in rendered + for forbidden in ("sk-json-secret", "escaped-tail-secret", "[PATH]"): + assert forbidden not in rendered + + +def test_emit_permission_audit_truncates_deep_input_summary_before_serializing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + nested: object = "leaf" + for _ in range(1500): + nested = {"next": nested} + record = PermissionAuditRecord( + session_id="s-summary-depth", + tool_name="bash", + tool_use_id="tu-summary-depth", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + input_summary={"tool_name": "bash", "fields": {"payload": nested}}, + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024 * 1024, max_files=2)) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert _has_truncated_object(row["input_summary"]) + + +def test_boundary_audit_helper_emits_prompt_record(monkeypatch) -> None: + records = [] + settings = PermissionAuditSettings(include_tool_input=True, max_file_bytes=123, max_files=2) + event = Mock( + tool_name="write_file", + tool_input={"path": "main.tf", "content": "resource {}", "access_key_secret": "secret-value"}, + tool_use_id="tool1", + audit_context={ + "session_id": "session-boundary", + "settings": settings, + "metadata": PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + is_read_only=False, + operation={"action": "CreateFile"}, + ), + }, + ) + + def fake_emit(record, settings=None): + records.append((record, settings)) + + monkeypatch.setattr("iac_code.services.permissions.audit.emit_permission_audit", fake_emit) + + emitted = emit_permission_boundary_audit( + event, + decision="allow", + scope="session_rule", + source="acp_prompt", + reason_detail="allow_rule:path:main.tf", + rule="write_file(path:main.tf)", + ) + + assert emitted is True + [(record, settings_seen)] = records + assert settings_seen is settings + assert record.session_id == "session-boundary" + assert record.tool_name == "write_file" + assert record.tool_use_id == "tool1" + assert record.source == "acp_prompt" + assert record.scope == "session_rule" + assert record.decision == "allow" + assert record.reason_detail == "allow_rule:path:main.tf" + assert record.rule == "write_file(path:main.tf)" + assert record.operation == {"action": "CreateFile", "is_read_only": False} + assert record.input_summary["fields"]["path"] == {"type": "str"} + assert record.tool_input_redacted == { + "path": {"type": "str", "length": 7, "fingerprint": fingerprint_text("main.tf")}, + "content": {"type": "str", "length": 11, "fingerprint": fingerprint_text("resource {}")}, + fingerprint_text("access_key_secret"): {"redacted": True}, + } + + +def test_boundary_audit_helper_emits_read_only_denial(monkeypatch) -> None: + records = [] + event = Mock( + tool_name="aliyun_api", + tool_input={"product": "ros", "action": "GetStack"}, + tool_use_id="tool1", + audit_context={ + "metadata": PermissionAuditMetadata(scope="read_only", source="permission_pipeline", is_read_only=True) + }, + ) + + monkeypatch.setattr( + "iac_code.services.permissions.audit.emit_permission_audit", + lambda record, settings=None: records.append(record), + ) + + assert is_permission_audit_non_read_only(event) is False + assert emit_permission_boundary_audit(event, decision="deny", scope="tool_cache", source="repl_tool_cache") is True + assert len(records) == 1 + assert records[0].decision == "deny" + assert records[0].scope == "tool_cache" + assert records[0].operation == {"is_read_only": True} + + +def test_sanitize_free_text_redacts_and_caps() -> None: + text = sanitize_free_text( + "accessKeyId=ak-secret x-api-key:api-secret token=secret-token " + "Signature=signature-secret Authorization: Bearer bearer-secret " + ("x" * 300), + max_chars=160, + ) + assert "ak-secret" not in text + assert "api-secret" not in text + assert "secret-token" not in text + assert "signature-secret" not in text + assert "bearer-secret" not in text + assert len(text) <= 160 + + +def test_emit_permission_audit_writes_jsonl_and_telemetry(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + telemetry = Mock() + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + record = PermissionAuditRecord( + session_id="s1", + tool_name="aliyun_api", + tool_use_id="tu1", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + rule_source="user_settings", + reason_type="rule", + rule="ros:CreateStack", + operation={"product": "ros", "action": "CreateStack", "region": "cn-hangzhou", "is_read_only": False}, + input_summary={"tool_name": "aliyun_api"}, + ) + + emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + log_path = tmp_path / "logs" / "permission-audit.jsonl" + rows = _read_jsonl(log_path) + assert rows[0]["decision"] == "allow" + assert rows[0]["rule_source"] == "user_settings" + telemetry.assert_called_once() + assert telemetry.call_args.args[0] == "iac.tool.permission.granted" + + +def test_emit_permission_audit_requests_durable_jsonl_append(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + append_calls = [] + + def fake_append(path, records, **kwargs): + append_calls.append((path, list(records), kwargs)) + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fake_append) + record = PermissionAuditRecord( + session_id="s-durable", + tool_name="aliyun_api", + tool_use_id="tu-durable", + decision="allow", + scope="once", + source="repl_prompt", + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + input_summary={"tool_name": "aliyun_api"}, + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is True + + [(_path, _records, kwargs)] = append_calls + assert kwargs["durable"] is True + assert kwargs["create_mode"] == 0o600 + + +def test_emit_permission_audit_skips_allow_telemetry_when_jsonl_write_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + telemetry = Mock() + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + + def fail_append(*args, **kwargs): + raise OSError("disk unavailable") + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fail_append) + record = PermissionAuditRecord( + session_id="s-fail", + tool_name="bash", + tool_use_id="tu-fail", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is False + telemetry.assert_not_called() + + +def test_emit_permission_audit_clamps_excessive_max_files_before_rotation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("iac_code.services.permissions.audit._log_path", lambda: tmp_path / "permission-audit.jsonl") + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + captured: dict[str, int] = {} + + def fake_append(path, records, **kwargs): + captured["append_max_files"] = kwargs["max_files"] + + def fake_private(path, *, max_files): + captured["private_max_files"] = max_files + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fake_append) + monkeypatch.setattr("iac_code.services.permissions.audit._ensure_audit_log_files_private", fake_private) + record = PermissionAuditRecord( + session_id="s-clamp", + tool_name="bash", + tool_use_id="tu-clamp", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_files=100000000)) is True + assert captured == {"append_max_files": 100, "private_max_files": 100} + + +def test_emit_permission_audit_restricts_jsonl_file_permissions(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + record = PermissionAuditRecord( + session_id="s-private", + tool_name="aliyun_api", + tool_use_id="tu-private", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + input_summary={"tool_name": "aliyun_api"}, + ) + + emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + if os.name != "nt": + mode = stat.S_IMODE((tmp_path / "logs" / "permission-audit.jsonl").stat().st_mode) + assert mode == 0o600 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not meaningful on Windows") +def test_emit_permission_audit_fails_when_jsonl_permissions_remain_too_broad( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("iac_code.services.permissions.audit._log_path", lambda: tmp_path / "permission-audit.jsonl") + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + monkeypatch.setattr("iac_code.services.permissions.audit.ensure_private_file", lambda path: path) + + def fake_append(path, records, **kwargs): + path.write_text(json.dumps(list(records)[0]) + "\n", encoding="utf-8") + path.chmod(0o666) + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fake_append) + record = PermissionAuditRecord( + session_id="s-broad", + tool_name="bash", + tool_use_id="tu-broad", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is False + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not meaningful on Windows") +def test_emit_permission_audit_refuses_existing_broad_jsonl_before_append( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + log_path = tmp_path / "permission-audit.jsonl" + log_path.write_text("", encoding="utf-8") + log_path.chmod(0o666) + monkeypatch.setattr("iac_code.services.permissions.audit._log_path", lambda: log_path) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + monkeypatch.setattr("iac_code.services.permissions.audit.ensure_private_file", lambda path: path) + append = Mock() + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", append) + record = PermissionAuditRecord( + session_id="s-existing-broad", + tool_name="bash", + tool_use_id="tu-existing-broad", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is False + append.assert_not_called() + + +def test_emit_permission_audit_omits_unsafe_rule_text_from_jsonl(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + record = PermissionAuditRecord( + session_id="s-private", + tool_name="bash", + tool_use_id="tu-private", + decision="allow", + scope="session_rule", + source="permission_pipeline", + rule_source="session", + reason_type="rule", + reason_detail="matched allow rule(s): bash(echo secret-value)", + rule="bash(echo secret-value)", + operation={"is_read_only": False}, + input_summary={"tool_name": "bash"}, + ) + + emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert row["reason_detail"] == "matched permission rule" + assert "rule" not in row + assert row["rule_fingerprint"] == fingerprint_text("bash(echo secret-value)") + assert "secret-value" not in json.dumps(row) + + +def test_emit_permission_audit_preserves_safe_multi_rule_text(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + rule = "bash(mkdir:*), bash(rm:*)" + record = PermissionAuditRecord( + session_id="s-private", + tool_name="bash", + tool_use_id="tu-private", + decision="allow", + scope="session_rule", + source="repl_prompt", + rule_source="session", + reason_type="prompt_selection", + reason_detail="always_allow_rule", + rule=rule, + operation={"is_read_only": False}, + input_summary={"tool_name": "bash"}, + ) + + emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert row["rule"] == rule + assert row["rule_fingerprint"] == fingerprint_text(rule) + + +def test_emit_permission_audit_excludes_tool_input_redacted_by_default(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + record = PermissionAuditRecord( + session_id="s-private", + tool_name="aliyun_api", + tool_use_id="tu-private", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + operation={"product": "ros", "action": "CreateStack"}, + input_summary={"tool_name": "aliyun_api"}, + tool_input_redacted={"access_key_secret": "secret-value"}, + ) + + emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert "tool_input_redacted" not in rows[0] + assert "secret-value" not in json.dumps(rows[0]) + + +def test_build_redacted_tool_input_truncates_deep_and_wide_objects() -> None: + nested: object = "leaf" + for _ in range(100): + nested = {"next": nested} + wide = {f"field_{index}": index for index in range(100)} + + redacted = build_redacted_tool_input({"nested": nested, "wide": wide}) + + nested_key = fingerprint_text("nested") + wide_key = fingerprint_text("wide") + assert _has_truncated_object(redacted[nested_key]) + assert redacted[wide_key]["_truncated"] == {"type": "object", "truncated": True} + assert "field_99" not in redacted[wide_key] + + +def test_build_redacted_tool_input_fingerprints_business_field_names() -> None: + redacted = build_redacted_tool_input( + { + "command": "git status", + "customerEmail": "alice@example.com", + "customer-prod-123": "tenant-id", + } + ) + + serialized = json.dumps(redacted) + assert "command" in redacted + assert "customerEmail" not in serialized + assert "customer-prod-123" not in serialized + assert redacted[fingerprint_text("customerEmail")] == { + "type": "str", + "length": len("alice@example.com"), + "fingerprint": fingerprint_text("alice@example.com"), + } + assert redacted[fingerprint_text("customer-prod-123")] == { + "type": "str", + "length": len("tenant-id"), + "fingerprint": fingerprint_text("tenant-id"), + } + + +def test_emit_permission_audit_truncates_redacted_tool_input_when_enabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + nested: object = "leaf" + for _ in range(100): + nested = {"next": nested} + wide = {f"field_{index}": index for index in range(100)} + record = PermissionAuditRecord( + session_id="s-truncate", + tool_name="bash", + tool_use_id="tu-truncate", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + tool_input_redacted={"nested": nested, "wide": wide}, + ) + + emit_permission_audit( + record, + settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024 * 1024, max_files=2), + ) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + nested_key = fingerprint_text("nested") + wide_key = fingerprint_text("wide") + assert _has_truncated_object(row["tool_input_redacted"][nested_key]) + assert row["tool_input_redacted"][wide_key]["_truncated"] == {"type": "object", "truncated": True} + assert "field_99" not in row["tool_input_redacted"][wide_key] + + +def test_emit_permission_audit_includes_redacted_tool_input_when_enabled(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + record = PermissionAuditRecord( + session_id="s-private", + tool_name="aliyun_api", + tool_use_id="tu-private", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + operation={"product": "ros", "action": "CreateStack"}, + input_summary={"tool_name": "aliyun_api"}, + tool_input_redacted={ + "accessKeyId": "ak-secret", + "apiKey": "api-secret", + "x-api-key": "dash-secret", + "cookie": "session-secret", + "Signature": "signature-secret", + "params": { + "StackName": "demo", + "AccessKeySecret": "secret-value", + "private_key": "private-secret", + "token=secret-token": "value", + }, + "headers": {"Authorization": "bearer secret-token", "apiKey=api-secret": "value"}, + "note": "token=secret-token", + }, + ) + + emit_permission_audit( + record, + settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), + ) + + rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert rows[0]["tool_input_redacted"] == { + fingerprint_text("accessKeyId"): {"redacted": True}, + fingerprint_text("apiKey"): {"redacted": True}, + fingerprint_text("x-api-key"): {"redacted": True}, + fingerprint_text("cookie"): {"redacted": True}, + fingerprint_text("Signature"): {"redacted": True}, + "params": { + fingerprint_text("StackName"): {"type": "str", "length": 4, "fingerprint": fingerprint_text("demo")}, + fingerprint_text("AccessKeySecret"): {"redacted": True}, + fingerprint_text("private_key"): {"redacted": True}, + fingerprint_text("token=secret-token"): {"redacted": True}, + }, + "headers": { + fingerprint_text("Authorization"): {"redacted": True}, + fingerprint_text("apiKey=api-secret"): {"redacted": True}, + }, + fingerprint_text("note"): {"type": "str", "length": 18, "fingerprint": fingerprint_text("token=secret-token")}, + } + assert "ak-secret" not in json.dumps(rows[0]) + assert "api-secret" not in json.dumps(rows[0]) + assert "dash-secret" not in json.dumps(rows[0]) + assert "session-secret" not in json.dumps(rows[0]) + assert "signature-secret" not in json.dumps(rows[0]) + assert "private-secret" not in json.dumps(rows[0]) + assert "secret-value" not in json.dumps(rows[0]) + assert "secret-token" not in json.dumps(rows[0]) + assert "accessKeyId" not in json.dumps(rows[0]) + assert "AccessKeySecret" not in json.dumps(rows[0]) + assert "Signature" not in json.dumps(rows[0]) + assert "private_key" not in json.dumps(rows[0]) + assert "apiKey=api-secret" not in json.dumps(rows[0]) + assert "demo" not in json.dumps(rows[0]) + + +def test_emit_permission_audit_redacts_embedded_json_and_pem_strings(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + pem = "-----BEGIN PRIVATE KEY-----\nprivate-body\n-----END PRIVATE KEY-----" + record = PermissionAuditRecord( + session_id="s-private", + tool_name="aliyun_api", + tool_use_id="tu-private", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + operation={"product": "ros", "action": "CreateStack"}, + input_summary={"tool_name": "aliyun_api"}, + tool_input_redacted={ + "params": { + "payload": '{"AccessKeySecret":"super-secret"}', + "TemplateBody": pem, + }, + }, + ) + + emit_permission_audit( + record, + settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), + ) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + rendered = json.dumps(row, ensure_ascii=False) + assert "super-secret" not in rendered + assert "private-body" not in rendered + assert "BEGIN PRIVATE KEY" not in rendered + assert "AccessKeySecret" not in rendered + + +def test_emit_permission_audit_tool_input_redaction_omits_business_payloads(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + record = PermissionAuditRecord( + session_id="s-private", + tool_name="aliyun_api", + tool_use_id="tu-private", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + operation={"product": "ros", "action": "CreateStack"}, + input_summary={"tool_name": "aliyun_api"}, + tool_input_redacted={ + "product": "ros", + "action": "CreateStack", + "region_id": "cn-hangzhou", + "params": { + "StackName": "customer-stack-name", + "TemplateBody": "Resources: proprietary-template-body", + "UserData": "#!/bin/bash\ncurl https://internal.example", + "CommandContent": "echo proprietary-command", + }, + "body": {"PolicyDocument": '{"Statement": "customer-policy"}'}, + }, + ) + + emit_permission_audit( + record, + settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), + ) + + [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + serialized = json.dumps(row, ensure_ascii=False) + assert row["tool_input_redacted"]["product"] == { + "type": "str", + "length": 3, + "fingerprint": fingerprint_text("ros"), + } + assert "customer-stack-name" not in serialized + assert "proprietary-template-body" not in serialized + assert "internal.example" not in serialized + assert "proprietary-command" not in serialized + assert "customer-policy" not in serialized + + +def test_emit_permission_audit_uses_minimal_private_telemetry_metadata(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + telemetry = Mock() + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + record = PermissionAuditRecord( + session_id="s-private", + tool_name="aliyun_api", + tool_use_id="tu-private", + decision="deny", + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + reason_type="rule", + reason_detail="token=secret-token permission denied", + rule="ros:DeleteStack", + rule_fingerprint="raw-rule-fingerprint-secret", + operation={ + "product": "ros*operation-secret", + "action": "DeleteStack operation-secret", + "region": "cn-hangzhou/operation-secret", + "product_fingerprint": "raw-product-fingerprint-secret", + "action_fingerprint": "raw-action-fingerprint-secret", + "region_fingerprint": "raw-region-fingerprint-secret", + "is_read_only": False, + "access_key_secret": "operation-secret", + }, + input_summary={"tool_name": "aliyun_api", "params_fields": ["StackName"]}, + tool_input_redacted={"params": {"StackName": {"redacted": True}}}, + ) + + emit_permission_audit( + record, + settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), + ) + + rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert rows[0]["reason_detail"] == "matched permission rule" + assert rows[0]["tool_input_redacted"] == {"params": {fingerprint_text("StackName"): {"redacted": True}}} + assert rows[0]["operation"] == { + "product_fingerprint": fingerprint_text("ros*operation-secret"), + "action_fingerprint": fingerprint_text("DeleteStack operation-secret"), + "region_fingerprint": fingerprint_text("cn-hangzhou/operation-secret"), + "is_read_only": False, + } + audit_serialized = json.dumps(rows[0]) + for forbidden in ( + "access_key_secret", + "operation-secret", + "ros*operation-secret", + "DeleteStack operation-secret", + "cn-hangzhou/operation-secret", + "raw-rule-fingerprint-secret", + "raw-product-fingerprint-secret", + "raw-action-fingerprint-secret", + "raw-region-fingerprint-secret", + ): + assert forbidden not in audit_serialized + + telemetry.assert_called_once() + metadata = telemetry.call_args.args[1] + assert metadata == { + "tool_name": "aliyun_api", + "decision": "deny", + "scope": "settings_rule", + "source": "permission_pipeline", + "rule_source": "project_settings", + "reason_type": "rule", + "product_fingerprint": fingerprint_text("ros*operation-secret"), + "action_fingerprint": fingerprint_text("DeleteStack operation-secret"), + "region_fingerprint": fingerprint_text("cn-hangzhou/operation-secret"), + "is_read_only": False, + "rule_fingerprint": fingerprint_text("ros:DeleteStack"), + } + serialized = json.dumps(metadata) + for forbidden in ( + "session_id", + "tool_use_id", + "timestamp", + "input_summary", + "tool_input_redacted", + "reason_detail", + "s-private", + "tu-private", + "secret-token", + "operation-secret", + "StackName", + ): + assert forbidden not in serialized + + +def test_emit_permission_audit_sanitizes_unsafe_telemetry_tokens(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + telemetry = Mock() + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + record = PermissionAuditRecord( + session_id="s-private", + tool_name="bash", + tool_use_id="tu-private", + decision="deny", + scope="settings_rule", + source="permission_pipeline", + rule_source="secret=abc", + reason_type="token=secret-token", + ) + + emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + telemetry.assert_called_once() + metadata = telemetry.call_args.args[1] + assert metadata["rule_source"] == fingerprint_text("secret=abc") + assert metadata["reason_type"] == fingerprint_text("token=secret-token") + serialized = json.dumps(metadata) + assert "secret=abc" not in serialized + assert "token=secret-token" not in serialized + + +def test_emit_permission_audit_telemetry_failure_does_not_prevent_jsonl(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + telemetry = Mock(side_effect=RuntimeError("telemetry unavailable")) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + record = PermissionAuditRecord( + session_id="s1", + tool_name="aliyun_api", + tool_use_id="tu1", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + operation={"product": "ros", "action": "CreateStack", "region": "cn-hangzhou"}, + input_summary={"tool_name": "aliyun_api"}, + ) + + emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + assert rows[0]["decision"] == "allow" + telemetry.assert_called_once() diff --git a/tests/services/permissions/test_loader.py b/tests/services/permissions/test_loader.py index 71812858..8a205682 100644 --- a/tests/services/permissions/test_loader.py +++ b/tests/services/permissions/test_loader.py @@ -2,9 +2,12 @@ from __future__ import annotations +from pathlib import Path + import pytest import yaml +import iac_code.services.permissions.loader as loader from iac_code.services.permissions.loader import load_permission_context, load_settings_permissions from iac_code.types.permissions import PermissionMode @@ -112,3 +115,96 @@ def test_load_permission_context_initializes_trusted_read_directories(tmp_path, ctx = load_permission_context(str(tmp_path)) assert ctx.trusted_read_directories == [] + + +def test_load_permission_context_merges_audit_settings(tmp_path: Path, monkeypatch) -> None: + config_dir = tmp_path / "config" + project_dir = tmp_path / "project" + config_dir.mkdir() + project_dir.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + (config_dir / "settings.yml").write_text( + "permissions:\n audit:\n include_tool_input: false\n max_file_bytes: 100\n max_files: 1\n", + encoding="utf-8", + ) + (project_dir / ".iac-code").mkdir() + (project_dir / ".iac-code" / "settings.yml").write_text( + "permissions:\n audit:\n max_file_bytes: 200\n", + encoding="utf-8", + ) + (project_dir / ".iac-code" / "settings.local.yml").write_text( + "permissions:\n audit:\n include_tool_input: true\n max_files: 3\n", + encoding="utf-8", + ) + + context = load_permission_context(str(project_dir)) + + assert context.audit_settings.include_tool_input is True + assert context.audit_settings.max_file_bytes == 200 + assert context.audit_settings.max_files == 3 + + +def test_load_permission_context_env_overrides_audit_tool_input(tmp_path: Path, monkeypatch) -> None: + config_dir = tmp_path / "config" + project_dir = tmp_path / "project" + config_dir.mkdir() + project_dir.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + monkeypatch.setenv("IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT", "1") + (config_dir / "settings.yml").write_text( + "permissions:\n audit:\n include_tool_input: false\n", + encoding="utf-8", + ) + + context = load_permission_context(str(project_dir)) + + assert context.audit_settings.include_tool_input is True + + +def test_load_permission_context_ignores_invalid_audit_retention( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_dir = tmp_path / "config" + project_dir = tmp_path / "project" + config_dir.mkdir() + project_dir.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + (config_dir / "settings.yml").write_text( + "permissions:\n audit:\n max_file_bytes: -1\n max_files: nope\n", + encoding="utf-8", + ) + warnings: list[str] = [] + + monkeypatch.setattr(loader.logger, "warning", lambda message, *args: warnings.append(message.format(*args))) + + context = load_permission_context(str(project_dir)) + + assert context.audit_settings.max_file_bytes == 10 * 1024 * 1024 + assert context.audit_settings.max_files == 5 + assert any("Invalid permissions.audit.max_file_bytes" in warning for warning in warnings) + assert any("Invalid permissions.audit.max_files" in warning for warning in warnings) + + +def test_load_permission_context_clamps_excessive_audit_max_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_dir = tmp_path / "config" + project_dir = tmp_path / "project" + config_dir.mkdir() + project_dir.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + (config_dir / "settings.yml").write_text( + "permissions:\n audit:\n max_files: 100000000\n", + encoding="utf-8", + ) + warnings: list[str] = [] + + monkeypatch.setattr(loader.logger, "warning", lambda message, *args: warnings.append(message.format(*args))) + + context = load_permission_context(str(project_dir)) + + assert context.audit_settings.max_files == 100 + assert any("permissions.audit.max_files value 100000000 exceeds maximum 100" in warning for warning in warnings) diff --git a/tests/services/permissions/test_pipeline.py b/tests/services/permissions/test_pipeline.py index eaa2fd54..a1d7ed5d 100644 --- a/tests/services/permissions/test_pipeline.py +++ b/tests/services/permissions/test_pipeline.py @@ -3,6 +3,7 @@ from iac_code.services.permissions.pipeline import check_tool_permission from iac_code.tools.base import Tool, ToolResult from iac_code.tools.bash.bash_tool import BashTool +from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi from iac_code.tools.edit_file import EditFileTool from iac_code.tools.glob import GlobTool from iac_code.tools.grep import GrepTool @@ -264,18 +265,123 @@ async def test_tool_level_deny(self): r = await check_tool_permission(FakeWriteTool(), {}, ctx) assert r.behavior == "deny" + @pytest.mark.asyncio + async def test_deny_rule_includes_audit_metadata(self): + r = await check_tool_permission(FakeWriteTool(), {}, _ctx(deny={"user_settings": ["write_file"]})) + assert r.behavior == "deny" + assert r.audit is not None + assert r.audit.scope == "settings_rule" + assert r.audit.rule_source == "user_settings" + assert r.audit.reason_detail == "matched deny rule: write_file" + + @pytest.mark.asyncio + async def test_cli_bare_deny_rule_includes_cli_rule_audit_scope(self): + r = await check_tool_permission(FakeWriteTool(), {}, _ctx(deny={"cli_arg": ["write_file"]})) + assert r.behavior == "deny" + assert r.audit is not None + assert r.audit.scope == "cli_rule" + assert r.audit.rule_source == "cli_arg" + assert r.audit.rule == "write_file" + + @pytest.mark.asyncio + async def test_tool_level_ask_rule_includes_audit_metadata(self): + r = await check_tool_permission(FakeReadTool(), {}, _ctx(ask={"project_settings": ["read_file"]})) + assert r.behavior == "ask" + assert r.audit is not None + assert r.audit.scope == "settings_rule" + assert r.audit.rule_source == "project_settings" + assert r.audit.rule == "read_file" + assert r.audit.reason_type == "rule" + assert r.audit.reason_detail == "matched ask rule(s): read_file" + assert r.audit.is_read_only is True + @pytest.mark.asyncio async def test_tool_level_allow(self): ctx = _ctx(allow={"user_settings": ["write_file"]}) r = await check_tool_permission(FakeWriteTool(), {}, ctx) assert r.behavior == "allow" + @pytest.mark.asyncio + async def test_bare_allow_rule_includes_audit_metadata(self): + r = await check_tool_permission(FakeWriteTool(), {}, _ctx(allow={"user_settings": ["write_file"]})) + assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "settings_rule" + assert r.audit.rule_source == "user_settings" + assert r.audit.rule == "write_file" + assert r.audit.reason_detail == "matched allow rule: write_file" + + @pytest.mark.asyncio + async def test_session_bare_allow_rule_includes_session_rule_audit_scope(self): + r = await check_tool_permission(FakeWriteTool(), {}, _ctx(allow={"session": ["write_file"]})) + assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "session_rule" + assert r.audit.rule_source == "session" + assert r.audit.rule == "write_file" + + @pytest.mark.asyncio + async def test_bare_aliyun_api_allow_does_not_auto_allow_aliyun_write(self): + r = await check_tool_permission( + AliyunApi(), + {"product": "ros", "action": "CreateStack"}, + _ctx(allow={"user_settings": ["aliyun_api"]}), + ) + assert r.behavior == "ask" + assert r.audit is not None + assert r.audit.scope == "once" + assert r.audit.operation["action"] == "CreateStack" + assert r.audit.is_read_only is False + @pytest.mark.asyncio async def test_bypass_mode_allows(self): ctx = _ctx(mode=PermissionMode.BYPASS_PERMISSIONS) r = await check_tool_permission(FakeWriteTool(), {}, ctx) assert r.behavior == "allow" + @pytest.mark.asyncio + async def test_bypass_mode_includes_audit_metadata(self): + r = await check_tool_permission(FakeWriteTool(), {}, _ctx(mode=PermissionMode.BYPASS_PERMISSIONS)) + assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "mode" + assert r.audit.rule_source == "mode" + + @pytest.mark.asyncio + async def test_aliyun_api_bypass_mode_allows_write_with_audit(self): + r = await check_tool_permission( + AliyunApi(), + {"product": "ros", "action": "CreateStack"}, + _ctx(mode=PermissionMode.BYPASS_PERMISSIONS), + ) + assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "mode" + assert r.audit.rule_source == "mode" + assert r.audit.reason_type == "bypass_permissions" + assert r.audit.operation["action"] == "CreateStack" + assert r.audit.is_read_only is False + + @pytest.mark.asyncio + async def test_aliyun_api_bypass_mode_preserves_explicit_write_rule_audit(self): + r = await check_tool_permission( + AliyunApi(), + {"product": "ros", "action": "CreateStack"}, + _ctx( + mode=PermissionMode.BYPASS_PERMISSIONS, + allow={"user_settings": ["aliyun_api(ros:CreateStack)"]}, + ), + ) + + assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "settings_rule" + assert r.audit.rule_source == "user_settings" + assert r.audit.rule == "ros:CreateStack" + assert r.audit.reason_type == "rule" + assert r.audit.operation["action"] == "CreateStack" + assert r.audit.is_read_only is False + @pytest.mark.parametrize( ("tool", "tool_input"), [ @@ -313,6 +419,29 @@ async def test_dont_ask_converts_to_deny(self): r = await check_tool_permission(FakeWriteTool(), {}, ctx) assert r.behavior == "deny" + @pytest.mark.asyncio + async def test_dont_ask_mode_includes_audit_metadata(self): + r = await check_tool_permission(FakeWriteTool(), {}, _ctx(mode=PermissionMode.DONT_ASK)) + assert r.behavior == "deny" + assert r.audit is not None + assert r.audit.scope == "mode" + assert r.audit.rule_source == "mode" + + @pytest.mark.asyncio + async def test_aliyun_api_dont_ask_mode_preserves_tool_audit_operation(self): + r = await check_tool_permission( + AliyunApi(), + {"product": "ros", "action": "CreateStack"}, + _ctx(mode=PermissionMode.DONT_ASK), + ) + assert r.behavior == "deny" + assert r.audit is not None + assert r.audit.scope == "mode" + assert r.audit.rule_source == "mode" + assert r.audit.reason_type == "dont_ask" + assert r.audit.operation["action"] == "CreateStack" + assert r.audit.is_read_only is False + @pytest.mark.asyncio async def test_default_mode_asks(self): ctx = _ctx(mode=PermissionMode.DEFAULT) diff --git a/tests/test_services/test_telemetry/test_names.py b/tests/test_services/test_telemetry/test_names.py index 3178ec40..4aa32d7a 100644 --- a/tests/test_services/test_telemetry/test_names.py +++ b/tests/test_services/test_telemetry/test_names.py @@ -65,6 +65,8 @@ def test_spec_events_are_all_defined(): "iac.tool.use.failed", "iac.tool.use.granted_in_prompt", "iac.tool.use.rejected_in_prompt", + "iac.tool.permission.granted", + "iac.tool.permission.rejected", "iac.template.generated", "iac.template.validated", "iac.deployment.started", @@ -86,6 +88,11 @@ def test_spec_events_are_all_defined(): assert not missing, f"Missing event constants: {missing}" +def test_permission_audit_events_are_defined(): + assert Events.TOOL_PERMISSION_GRANTED == "iac.tool.permission.granted" + assert Events.TOOL_PERMISSION_REJECTED == "iac.tool.permission.rejected" + + def test_spec_metrics_are_all_defined(): spec_metrics = { "iac.session.count", diff --git a/tests/tools/bash/test_permissions.py b/tests/tools/bash/test_permissions.py index b62a35a1..a28ac74d 100644 --- a/tests/tools/bash/test_permissions.py +++ b/tests/tools/bash/test_permissions.py @@ -28,12 +28,75 @@ async def test_deny_rule_blocks(self): r = await bash_tool_has_permission("rm -rf /", ctx) assert r.behavior == "deny" + @pytest.mark.asyncio + async def test_full_command_deny_rule_returns_audit_metadata(self): + ctx = _ctx(deny={"user_settings": ["bash(rm -rf /)"]}) + r = await bash_tool_has_permission("rm -rf /", ctx) + + assert r.behavior == "deny" + assert r.audit is not None + assert r.audit.scope == "settings_rule" + assert r.audit.source == "permission_pipeline" + assert r.audit.rule_source == "user_settings" + assert r.audit.rule == "bash(rm -rf /)" + assert r.audit.reason_type == "rule" + assert r.audit.reason_detail == r.message + assert r.audit.is_read_only is False + assert r.audit.operation == {"is_read_only": False} + @pytest.mark.asyncio async def test_allow_rule_passes(self): ctx = _ctx(allow={"user_settings": ["bash(git:*)"]}) r = await bash_tool_has_permission("git push", ctx) assert r.behavior == "allow" + @pytest.mark.asyncio + async def test_allow_rule_returns_audit_metadata_for_non_readonly_command(self): + ctx = _ctx(allow={"session": ["bash(mkdir:*)"]}) + r = await bash_tool_has_permission("mkdir foo", ctx) + + assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "session_rule" + assert r.audit.source == "permission_pipeline" + assert r.audit.rule_source == "session" + assert r.audit.rule == "bash(mkdir:*)" + assert r.audit.reason_type == "rule" + assert r.audit.reason_detail == r.message + assert r.audit.is_read_only is False + assert r.audit.operation == {"is_read_only": False} + + @pytest.mark.asyncio + async def test_ask_rule_returns_audit_metadata_for_non_readonly_command(self): + ctx = _ctx(ask={"project_settings": ["bash(mkdir:*)"]}) + r = await bash_tool_has_permission("mkdir foo", ctx) + + assert r.behavior == "ask" + assert r.audit is not None + assert r.audit.scope == "settings_rule" + assert r.audit.source == "permission_pipeline" + assert r.audit.rule_source == "project_settings" + assert r.audit.rule == "bash(mkdir:*)" + assert r.audit.reason_type == "rule" + assert r.audit.reason_detail == r.message + assert r.audit.is_read_only is False + assert r.audit.operation == {"is_read_only": False} + + @pytest.mark.asyncio + async def test_compound_allow_rule_returns_audit_metadata_for_write_subcommand(self): + ctx = _ctx(allow={"session": ["bash(mkdir:*)"]}) + r = await bash_tool_has_permission("cat file.txt && mkdir foo", ctx) + + assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "session_rule" + assert r.audit.source == "permission_pipeline" + assert r.audit.rule_source == "session" + assert r.audit.rule == "bash(mkdir:*)" + assert r.audit.reason_type == "rule" + assert r.audit.is_read_only is False + assert r.audit.operation == {"is_read_only": False} + @pytest.mark.asyncio async def test_unknown_command_asks(self): r = await bash_tool_has_permission("docker run img", _ctx()) @@ -50,6 +113,13 @@ async def test_accept_edits_allows_filesystem(self): ctx = _ctx(mode=PermissionMode.ACCEPT_EDITS) r = await bash_tool_has_permission("mkdir foo", ctx) assert r.behavior == "allow" + assert r.audit is not None + assert r.audit.scope == "mode" + assert r.audit.source == "permission_pipeline" + assert r.audit.rule_source == "mode" + assert r.audit.reason_type == "accept_edits" + assert r.audit.is_read_only is False + assert r.audit.operation == {"is_read_only": False} @pytest.mark.parametrize( "cmd", @@ -303,6 +373,15 @@ def test_deny_rule_first(self): cmd = SimpleCommand(text="git push origin main", argv=["git", "push", "origin", "main"]) r = bash_tool_check_permission(cmd, ctx) assert r.behavior == "deny" + assert r.audit is not None + assert r.audit.scope == "settings_rule" + assert r.audit.source == "permission_pipeline" + assert r.audit.rule_source == "user_settings" + assert r.audit.rule == "bash(git push:*)" + assert r.audit.reason_type == "rule" + assert r.audit.reason_detail == r.message + assert r.audit.is_read_only is False + assert r.audit.operation == {"is_read_only": False} def test_readonly_auto_allow(self): cmd = SimpleCommand(text="cat file.txt", argv=["cat", "file.txt"]) diff --git a/tests/tools/cloud/aliyun/test_aliyun_api.py b/tests/tools/cloud/aliyun/test_aliyun_api.py index f130ec4f..ef4fca69 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api.py @@ -8,6 +8,7 @@ import pytest +from iac_code.services.permissions.audit import fingerprint_text from iac_code.services.providers.aliyun import AliyunCredential from iac_code.services.providers.aliyun_oauth import AliyunOAuthError, AliyunOAuthReloginRequired from iac_code.tools.base import ToolContext @@ -106,6 +107,50 @@ def test_is_read_only_false_for_create(self, api: AliyunApi) -> None: def test_is_read_only_false_for_delete(self, api: AliyunApi) -> None: assert api.is_read_only({"action": "DeleteInstance"}) is False + @pytest.mark.parametrize("method", ["DELETE", "PUT", "POST"]) + def test_roa_write_methods_are_not_read_only_even_with_read_action(self, api: AliyunApi, method: str) -> None: + assert ( + api.is_read_only( + { + "product": "cs", + "action": "DescribeClusters", + "style": "ROA", + "method": method, + "pathname": "/clusters/c-123", + } + ) + is False + ) + + def test_roa_get_without_body_can_be_read_only(self, api: AliyunApi) -> None: + assert ( + api.is_read_only( + { + "product": "cs", + "action": "DescribeClusters", + "style": "ROA", + "method": "GET", + "pathname": "/clusters", + } + ) + is True + ) + + def test_roa_get_with_body_is_not_read_only(self, api: AliyunApi) -> None: + assert ( + api.is_read_only( + { + "product": "cs", + "action": "DescribeClusters", + "style": "ROA", + "method": "GET", + "pathname": "/clusters", + "body": {"force": True}, + } + ) + is False + ) + class TestAliyunApiVersionResolution: def test_known_product_resolves_version(self, api: AliyunApi) -> None: @@ -365,6 +410,71 @@ async def test_successful_call(self, api: AliyunApi, context: ToolContext, mock_ assert data == {"Instances": []} mock_client.call_api.assert_called_once() + @pytest.mark.parametrize("fails", [False, True]) + @pytest.mark.asyncio + async def test_execution_telemetry_fingerprints_unsafe_identifiers( + self, api: AliyunApi, context: ToolContext, mock_credentials, fails: bool + ) -> None: + product = "Ro*Secret" + action = "Create:Stack SECRET" + region = "CN-Hangzhou/Secret" + version = "2023-01-01/Secret" + mock_client = MagicMock() + if fails: + mock_client.call_api.side_effect = Exception( + "boom for {} {} {} {}".format(product.lower(), action.lower(), region.lower(), version.lower()) + ) + else: + mock_client.call_api.return_value = {"body": {"Result": "ok"}} + + with ( + patch.object(api, "_discover_endpoint", return_value=None), + patch("iac_code.tools.cloud.aliyun.aliyun_api.OpenApiClient", return_value=mock_client), + patch("iac_code.tools.cloud.aliyun.aliyun_api.log_event") as log_event, + patch("iac_code.tools.cloud.aliyun.aliyun_api.add_metric") as add_metric, + ): + result = await api.execute( + tool_input={ + "product": product, + "action": action, + "version": version, + "region_id": region, + }, + context=context, + ) + + assert result.is_error is fails + event_payload = log_event.call_args.args[1] + assert "api_service" not in event_payload + assert "api_name" not in event_payload + assert "region" not in event_payload + assert event_payload["api_service_fingerprint"] == fingerprint_text(product) + assert event_payload["api_name_fingerprint"] == fingerprint_text(action) + assert event_payload["region_fingerprint"] == fingerprint_text(region) + assert event_payload["api_version_fingerprint"] == fingerprint_text(version) + assert "api_version" not in event_payload + metric_attrs = add_metric.call_args_list[0].args[2] + assert metric_attrs["api_service"] == "unsafe" + telemetry_dump = json.dumps( + { + "events": [call.args for call in log_event.call_args_list], + "metrics": [call.args for call in add_metric.call_args_list], + }, + default=str, + ) + for raw_value in ( + product, + product.upper(), + product.lower(), + action, + action.lower(), + region, + region.lower(), + version, + version.lower(), + ): + assert raw_value not in telemetry_dump + @pytest.mark.asyncio async def test_ros_create_stack_emits_resource_observed_event(self, api: AliyunApi, mock_credentials) -> None: queue: asyncio.Queue = asyncio.Queue() diff --git a/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py new file mode 100644 index 00000000..8209b3dd --- /dev/null +++ b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py @@ -0,0 +1,298 @@ +import pytest + +from iac_code.services.permissions.audit import fingerprint_text +from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi +from iac_code.types.permissions import PermissionMode, ToolPermissionContext + + +def _ctx(*, allow=None, deny=None, ask=None, mode=PermissionMode.DEFAULT): + return ToolPermissionContext( + cwd="/tmp", + allow_rules=allow or {}, + deny_rules=deny or {}, + ask_rules=ask or {}, + mode=mode, + ) + + +@pytest.mark.asyncio +async def test_read_api_allows() -> None: + result = await AliyunApi().check_permissions({"product": "ecs", "action": "DescribeInstances"}, _ctx()) + assert result.behavior == "allow" + assert result.audit is not None + assert result.audit.scope == "read_only" + assert result.audit.is_read_only is True + + +@pytest.mark.asyncio +async def test_ros_preview_stack_is_readonly_case_insensitive() -> None: + result = await AliyunApi().check_permissions({"product": "ROS", "action": "PreviewStack"}, _ctx()) + assert result.behavior == "allow" + + +@pytest.mark.asyncio +async def test_write_api_asks_with_action_suggestion() -> None: + result = await AliyunApi().check_permissions({"product": "ros", "action": "CreateStack"}, _ctx()) + assert result.behavior == "ask" + assert result.audit is not None + assert result.audit.scope == "once" + assert result.suggestions is not None + assert result.suggestions[0].tool_name == "aliyun_api" + assert result.suggestions[0].rule_content == "ros:CreateStack" + + +@pytest.mark.asyncio +async def test_roa_write_method_asks_even_with_read_prefixed_action() -> None: + result = await AliyunApi().check_permissions( + { + "product": "cs", + "action": "DescribeClusters", + "style": "ROA", + "method": "DELETE", + "pathname": "/clusters/c-123", + }, + _ctx(), + ) + + assert result.behavior == "ask" + assert result.audit is not None + assert result.audit.scope == "once" + assert result.audit.is_read_only is False + assert result.suggestions is not None + assert result.suggestions[0].tool_name == "aliyun_api" + assert result.suggestions[0].rule_content == "cs:DescribeClusters" + + +@pytest.mark.asyncio +async def test_roa_write_method_honors_exact_product_action_allow_rule() -> None: + result = await AliyunApi().check_permissions( + { + "product": "cs", + "action": "DescribeClusters", + "style": "ROA", + "method": "DELETE", + "pathname": "/clusters/c-123", + }, + _ctx(allow={"session": ["aliyun_api(cs:DescribeClusters)"]}), + ) + + assert result.behavior == "allow" + assert result.audit is not None + assert result.audit.scope == "session_rule" + assert result.audit.rule_source == "session" + assert result.audit.rule == "cs:DescribeClusters" + assert result.audit.is_read_only is False + + +@pytest.mark.asyncio +async def test_roa_write_method_requires_exact_allow_rule() -> None: + result = await AliyunApi().check_permissions( + { + "product": "cs", + "action": "DescribeClusters", + "style": "ROA", + "method": "DELETE", + "pathname": "/clusters/c-123", + }, + _ctx(allow={"session": ["aliyun_api(cs:*)"]}), + ) + + assert result.behavior == "ask" + assert result.audit is not None + assert result.audit.scope == "once" + assert result.audit.is_read_only is False + + +@pytest.mark.asyncio +async def test_roa_write_method_still_honors_deny_rule() -> None: + result = await AliyunApi().check_permissions( + { + "product": "cs", + "action": "DescribeClusters", + "style": "ROA", + "method": "DELETE", + "pathname": "/clusters/c-123", + }, + _ctx(deny={"local_settings": ["aliyun_api(cs:DescribeClusters)"]}), + ) + + assert result.behavior == "deny" + assert result.audit is not None + assert result.audit.scope == "settings_rule" + assert result.audit.rule == "cs:DescribeClusters" + assert result.audit.is_read_only is False + + +@pytest.mark.parametrize( + "input", + [ + {"product": "ros", "action": None}, + {"product": "ros", "action": 123}, + {"product": 123, "action": "CreateStack"}, + ], +) +@pytest.mark.asyncio +async def test_malformed_product_or_action_fails_closed_without_suggestion(input: dict) -> None: + result = await AliyunApi().check_permissions(input, _ctx()) + assert result.behavior == "ask" + assert result.suggestions in (None, []) + + +@pytest.mark.parametrize( + "product, action", + [ + ("ro*", "CreateStack"), + ("ros", "Create:Stack"), + ("ros", "Create(Stack)"), + ("ros", "x" * 129), + ("ro*", "DescribeInstances"), + ("ros", "Describe:Stack"), + ("ros", "DescribeInstances token=secret"), + ("ros", "Get/../../CreateStack"), + ], +) +@pytest.mark.asyncio +async def test_unsafe_values_do_not_get_persistent_suggestions(product: str, action: str) -> None: + result = await AliyunApi().check_permissions({"product": product, "action": action}, _ctx()) + assert result.behavior == "ask" + assert result.audit is not None + assert result.audit.is_read_only is False + assert result.suggestions in (None, []) + + +@pytest.mark.asyncio +async def test_unsafe_values_are_fingerprinted_in_operation_metadata() -> None: + result = await AliyunApi().check_permissions( + { + "product": "ro*secret", + "action": "Create:Stack secret", + "region_id": "cn-hangzhou/secret", + }, + _ctx(), + ) + + assert result.audit is not None + assert result.audit.operation == { + "product_fingerprint": fingerprint_text("ro*secret"), + "action_fingerprint": fingerprint_text("Create:Stack secret"), + "region_fingerprint": fingerprint_text("cn-hangzhou/secret"), + } + + +@pytest.mark.asyncio +async def test_wildcard_rule_does_not_allow_unsafe_runtime_action() -> None: + context = _ctx(allow={"project_settings": ["aliyun_api(ros:*)"]}) + result = await AliyunApi().check_permissions({"product": "ros", "action": "Create:Stack"}, context) + assert result.behavior == "ask" + assert result.suggestions in (None, []) + + +@pytest.mark.asyncio +async def test_wildcard_rule_does_not_allow_unsafe_runtime_product() -> None: + context = _ctx(allow={"project_settings": ["aliyun_api(ro*:CreateStack)"]}) + result = await AliyunApi().check_permissions({"product": "ro*", "action": "CreateStack"}, context) + assert result.behavior == "ask" + assert result.suggestions in (None, []) + + +@pytest.mark.parametrize("input", [{"product": "ros"}, {"action": "CreateStack"}]) +@pytest.mark.asyncio +async def test_missing_product_or_action_asks_without_suggestion(input: dict) -> None: + result = await AliyunApi().check_permissions(input, _ctx(allow={"project_settings": ["aliyun_api(ros:*)"]})) + assert result.behavior == "ask" + assert result.suggestions in (None, []) + + +@pytest.mark.asyncio +async def test_exact_rule_allows_only_matching_action() -> None: + context = _ctx(allow={"user_settings": ["aliyun_api(ros:CreateStack)"]}) + allowed = await AliyunApi().check_permissions({"product": "ros", "action": "CreateStack"}, context) + asked = await AliyunApi().check_permissions({"product": "ros", "action": "DeleteStack"}, context) + assert allowed.behavior == "allow" + assert allowed.audit is not None + assert allowed.audit.scope == "settings_rule" + assert allowed.audit.rule == "ros:CreateStack" + assert allowed.audit.rule_source == "user_settings" + assert asked.behavior == "ask" + + +@pytest.mark.asyncio +async def test_wildcard_allow_rule_does_not_allow_write_api() -> None: + context = _ctx(allow={"project_settings": ["aliyun_api(ros:*)"]}) + result = await AliyunApi().check_permissions({"product": "ros", "action": "UpdateStack"}, context) + assert result.behavior == "ask" + + +@pytest.mark.asyncio +async def test_wildcard_allow_rule_can_match_read_api() -> None: + context = _ctx(allow={"project_settings": ["aliyun_api(ros:*)"]}) + result = await AliyunApi().check_permissions({"product": "ros", "action": "GetStack"}, context) + assert result.behavior == "allow" + assert result.audit is not None + assert result.audit.scope == "settings_rule" + assert result.audit.rule == "ros:*" + assert result.audit.rule_source == "project_settings" + + +@pytest.mark.asyncio +async def test_deny_and_ask_precedence() -> None: + context = _ctx( + allow={"user_settings": ["aliyun_api(ros:CreateStack)"]}, + ask={"project_settings": ["aliyun_api(ros:Create*)"]}, + deny={"local_settings": ["aliyun_api(ros:*)"]}, + ) + result = await AliyunApi().check_permissions({"product": "ros", "action": "CreateStack"}, context) + assert result.behavior == "deny" + assert result.audit is not None + assert result.audit.rule == "ros:*" + assert result.audit.rule_source == "local_settings" + + +@pytest.mark.asyncio +async def test_specificity_is_product_first() -> None: + context = _ctx(ask={"session": ["aliyun_api(ro*:CreateStack)", "aliyun_api(ros:*)"]}) + result = await AliyunApi().check_permissions({"product": "ros", "action": "CreateStack"}, context) + assert result.behavior == "ask" + assert result.audit is not None + assert result.audit.scope == "session_rule" + assert result.audit.rule == "ros:*" + + +@pytest.mark.asyncio +async def test_same_source_equal_specificity_prefers_later_rule() -> None: + context = _ctx( + ask={ + "user_settings": [ + "aliyun_api(ro*:CreateStack)", + "aliyun_api(r*s:CreateStack)", + ] + } + ) + + result = await AliyunApi().check_permissions({"product": "ros", "action": "CreateStack"}, context) + + assert result.behavior == "ask" + assert result.audit is not None + assert result.audit.rule == "r*s:CreateStack" + + +@pytest.mark.parametrize( + ("behavior", "rules_key"), + [("allow", "allow"), ("deny", "deny"), ("ask", "ask")], +) +@pytest.mark.asyncio +async def test_cli_action_rules_use_cli_scope(behavior: str, rules_key: str) -> None: + rules = {"cli_arg": ["aliyun_api(ros:CreateStack)"]} + context = _ctx(**{rules_key: rules}) + + result = await AliyunApi().check_permissions({"product": "ros", "action": "CreateStack"}, context) + + assert result.behavior == behavior + assert result.audit is not None + assert result.audit.scope == "cli_rule" + assert result.audit.rule_source == "cli_arg" + assert result.audit.rule == "ros:CreateStack" + + +def test_aliyun_api_disables_blanket_allow() -> None: + assert AliyunApi().supports_blanket_allow is False diff --git a/tests/types/test_permissions_types.py b/tests/types/test_permissions_types.py index 2cb98ede..05b2a37e 100644 --- a/tests/types/test_permissions_types.py +++ b/tests/types/test_permissions_types.py @@ -86,6 +86,19 @@ def test_passthrough_behavior(self) -> None: assert result.behavior == "passthrough" +def test_permission_audit_metadata_defaults_to_safe_values() -> None: + from iac_code.types.permissions import PermissionAuditMetadata, PermissionResult + + metadata = PermissionAuditMetadata(scope="settings_rule", source="permission_pipeline") + result = PermissionResult(behavior="allow", audit=metadata) + + assert result.audit is metadata + assert result.audit.scope == "settings_rule" + assert result.audit.source == "permission_pipeline" + assert result.audit.rule_source is None + assert result.audit.is_read_only is None + + class TestPermissionDecisionReason: def test_creation(self) -> None: reason = PermissionDecisionReason(type="rule", detail="matched bash(git *)") @@ -131,3 +144,14 @@ def test_all_rules_from_source(self) -> None: for rules in ctx.allow_rules.values(): all_allow.extend(rules) assert len(all_allow) == 2 + + +def test_tool_permission_context_carries_audit_settings() -> None: + from iac_code.types.permissions import PermissionAuditSettings, ToolPermissionContext + + settings = PermissionAuditSettings(include_tool_input=True, max_file_bytes=4096, max_files=2) + context = ToolPermissionContext(cwd="/tmp/project", audit_settings=settings) + + assert context.audit_settings.include_tool_input is True + assert context.audit_settings.max_file_bytes == 4096 + assert context.audit_settings.max_files == 2 diff --git a/tests/ui/test_permission_audit_prompt.py b/tests/ui/test_permission_audit_prompt.py new file mode 100644 index 00000000..ccb02a49 --- /dev/null +++ b/tests/ui/test_permission_audit_prompt.py @@ -0,0 +1,373 @@ +"""Tests for REPL permission prompt audit behavior.""" + +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from unittest.mock import MagicMock, patch + +import pytest +from rich.console import Console + +from iac_code.state.app_state import AppState, AppStateStore +from iac_code.types.permissions import ( + PermissionAuditMetadata, + PermissionAuditSettings, + PermissionResult, + PermissionRuleValue, + ToolPermissionContext, +) +from iac_code.types.stream_events import PermissionRequestEvent +from iac_code.ui.renderer import Renderer + + +def _make_renderer(app_state_store=None, tool=None) -> Renderer: + console = Console(record=True) + tool_registry = MagicMock() + tool_registry.get.return_value = tool + return Renderer(console, tool_registry, app_state_store=app_state_store) + + +def _make_event( + tool_name: str = "write_file", + *, + tool_input: dict | None = None, + permission_result: PermissionResult | None = None, + audit_metadata: PermissionAuditMetadata | None = None, + audit_settings: PermissionAuditSettings | None = None, +) -> PermissionRequestEvent: + fut: asyncio.Future[bool] = asyncio.get_event_loop().create_future() + audit_context = None + if audit_metadata is not None or audit_settings is not None: + audit_context = { + "session_id": "session-audit", + "settings": audit_settings, + "metadata": audit_metadata, + } + return PermissionRequestEvent( + tool_name=tool_name, + tool_input=tool_input or {"path": "main.tf", "content": "resource {}"}, + tool_use_id="t1", + response_future=fut, + permission_result=permission_result, + audit_context=audit_context, + ) + + +def _patch_select(return_value): + """Patch Select.run to return a predetermined value.""" + return patch("iac_code.ui.components.select.Select.run", return_value=return_value) + + +@pytest.fixture +def audit_records(monkeypatch): + records = [] + + def fake_emit(record, settings=None): + records.append((record, settings)) + + monkeypatch.setattr("iac_code.services.permissions.audit.emit_permission_audit", fake_emit) + return records + + +class _RawAliyunDisplayTool: + supports_blanket_allow = False + + def user_facing_name(self, input=None): + return "CloudAPI" + + def render_tool_use_message(self, input, *, verbose=False): + return "{} {}".format(input.get("action", ""), input.get("region_id", "")) + + +@pytest.mark.asyncio +async def test_renderer_ignores_stale_aliyun_cached_allow_for_write(audit_records) -> None: + store = AppStateStore(AppState(always_allow_rules=OrderedDict([("aliyun_api", "always_allow")]))) + renderer = _make_renderer(store) + event = _make_event( + "aliyun_api", + tool_input={"product": "ros", "action": "CreateStack"}, + audit_metadata=PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + is_read_only=False, + operation={"product": "ros", "action": "CreateStack", "is_read_only": False}, + ), + ) + + with _patch_select("reject_once") as mock_run: + result = await renderer.prompt_permission(event) + + assert result is False + mock_run.assert_called_once() + assert "aliyun_api" not in store.get_state().always_allow_rules + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.decision == "deny" + assert record.reason_detail == "reject_once" + + +@pytest.mark.asyncio +async def test_renderer_permission_prompt_summarizes_aliyun_detail(audit_records) -> None: + store = AppStateStore() + renderer = _make_renderer(store, tool=_RawAliyunDisplayTool()) + event = _make_event( + "aliyun_api", + tool_input={ + "product": "ROS", + "action": "CreateStack Signature=signature-secret", + "region_id": "cn-hangzhou Authorization=Bearer bearer-secret", + "params": {"AccessKeySecret": "secret-value"}, + }, + audit_metadata=PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + is_read_only=False, + operation={"product": "ROS", "action": "CreateStack", "is_read_only": False}, + ), + ) + + with _patch_select("allow_once"): + result = await renderer.prompt_permission(event) + + assert result is True + rendered = renderer.console.export_text() + assert "Input summary:" in rendered + assert "signature-secret" not in rendered + assert "bearer-secret" not in rendered + assert "secret-value" not in rendered + assert "AccessKeySecret" not in rendered + + +@pytest.mark.asyncio +async def test_renderer_audits_tool_cache_deny_for_write(audit_records) -> None: + store = AppStateStore(AppState(always_allow_rules=OrderedDict([("write_file", "always_deny")]))) + renderer = _make_renderer(store) + event = _make_event("write_file") + + with _patch_select("allow_once") as mock_run: + result = await renderer.prompt_permission(event) + + assert result is False + mock_run.assert_not_called() + [(record, _settings)] = audit_records + assert record.source == "repl_tool_cache" + assert record.scope == "tool_cache" + assert record.rule_source == "tool_cache" + assert record.decision == "deny" + assert record.tool_use_id == "t1" + + +@pytest.mark.asyncio +async def test_renderer_prompt_allow_once_emits_one_prompt_event(audit_records) -> None: + store = AppStateStore() + renderer = _make_renderer(store) + event = _make_event("write_file") + + with _patch_select("allow_once"): + result = await renderer.prompt_permission(event) + + assert result is True + assert len(store.get_state().always_allow_rules) == 0 + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.scope == "once" + assert record.decision == "allow" + assert record.reason_detail == "allow_once" + + +@pytest.mark.asyncio +async def test_renderer_prompt_allow_denies_when_audit_log_write_fails(monkeypatch) -> None: + def fail_append(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fail_append) + store = AppStateStore() + renderer = _make_renderer(store) + event = _make_event( + "write_file", + audit_metadata=PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + is_read_only=False, + ), + ) + + with _patch_select("allow_once"): + result = await renderer.prompt_permission(event) + + assert result is False + + +@pytest.mark.asyncio +async def test_renderer_prompt_preserves_triggering_ask_rule_provenance(audit_records) -> None: + store = AppStateStore() + renderer = _make_renderer(store) + event = _make_event( + "read_file", + audit_metadata=PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="read_file", + reason_type="rule", + reason_detail="matched ask rule(s): read_file", + is_read_only=True, + ), + ) + + with _patch_select("allow_once"): + result = await renderer.prompt_permission(event) + + assert result is True + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.scope == "once" + assert record.decision == "allow" + assert record.reason_type == "prompt_selection" + assert record.reason_detail == "allow_once" + assert record.rule_source == "project_settings" + assert record.rule == "read_file" + + +@pytest.mark.asyncio +async def test_renderer_prompt_preserves_non_rule_trigger_reason(audit_records) -> None: + store = AppStateStore() + renderer = _make_renderer(store) + event = _make_event( + "write_file", + audit_metadata=PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="path_constraint", + reason_detail="path_constraint", + is_read_only=False, + ), + ) + + with _patch_select("allow_once"): + result = await renderer.prompt_permission(event) + + assert result is True + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.reason_type == "prompt_selection" + assert record.reason_detail == "allow_once" + assert record.trigger_reason_type == "path_constraint" + + +@pytest.mark.asyncio +async def test_renderer_prompt_always_allow_tool_cache_records_tool_cache_source(audit_records) -> None: + store = AppStateStore() + renderer = _make_renderer(store) + event = _make_event( + "write_file", + audit_metadata=PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="write_file", + reason_type="rule", + reason_detail="matched ask rule(s): write_file", + is_read_only=False, + ), + ) + + with _patch_select("always_allow"): + result = await renderer.prompt_permission(event) + + assert result is True + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.scope == "tool_cache" + assert record.decision == "allow" + assert record.rule_source == "tool_cache" + + +@pytest.mark.asyncio +async def test_renderer_prompt_cancel_emits_reject_once_prompt_event(audit_records) -> None: + store = AppStateStore() + renderer = _make_renderer(store) + event = _make_event("write_file") + + with _patch_select(None): + result = await renderer.prompt_permission(event) + + assert result is False + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.scope == "once" + assert record.decision == "deny" + assert record.reason_detail == "reject_once" + + +@pytest.mark.asyncio +async def test_renderer_prompt_always_allow_rule_records_session_rule_audit(audit_records) -> None: + store = AppStateStore(AppState(permission_context=ToolPermissionContext())) + renderer = _make_renderer(store) + event = _make_event( + "bash", + tool_input={"command": "mkdir foo"}, + audit_metadata=PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="bash(mkdir:*)", + reason_type="rule", + reason_detail="matched ask rule(s): bash(mkdir:*)", + is_read_only=False, + ), + permission_result=PermissionResult( + behavior="ask", + suggestions=[PermissionRuleValue(tool_name="bash", rule_content="mkdir:*")], + ), + ) + + with _patch_select("always_allow_rule"): + result = await renderer.prompt_permission(event) + + assert result is True + allow_rules = store.get_state().permission_context.allow_rules.get("session", []) + assert any("mkdir:*" in rule for rule in allow_rules) + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.scope == "session_rule" + assert record.decision == "allow" + assert record.rule_source == "session" + assert record.rule == "bash(mkdir:*)" + + +@pytest.mark.asyncio +async def test_renderer_prompt_always_deny_rule_records_session_rule_source(audit_records) -> None: + store = AppStateStore(AppState(permission_context=ToolPermissionContext())) + renderer = _make_renderer(store) + event = _make_event( + "bash", + tool_input={"command": "rm foo"}, + audit_metadata=PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + rule="bash(rm:*)", + reason_type="rule", + reason_detail="matched ask rule(s): bash(rm:*)", + is_read_only=False, + ), + permission_result=PermissionResult( + behavior="ask", + suggestions=[PermissionRuleValue(tool_name="bash", rule_content="rm:*")], + ), + ) + + with _patch_select("always_deny_rule"): + result = await renderer.prompt_permission(event) + + assert result is False + deny_rules = store.get_state().permission_context.deny_rules.get("session", []) + assert any("rm:*" in rule for rule in deny_rules) + [(record, _settings)] = audit_records + assert record.source == "repl_prompt" + assert record.scope == "session_rule" + assert record.decision == "deny" + assert record.rule_source == "session" + assert record.rule == "bash(rm:*)" diff --git a/tests/ui/test_renderer_events.py b/tests/ui/test_renderer_events.py index 604f2b41..8d8354b9 100644 --- a/tests/ui/test_renderer_events.py +++ b/tests/ui/test_renderer_events.py @@ -3,10 +3,12 @@ import pytest +from iac_code.services.telemetry.names import Events from iac_code.tools.base import ToolRegistry from iac_code.types.stream_events import ( MessageEndEvent, MessageStartEvent, + PermissionRequestEvent, TaskNotificationEvent, TextDeltaEvent, ThinkingDeltaEvent, @@ -78,6 +80,47 @@ async def events(): await renderer.run_streaming_output(events(), permission_handler=None) + @pytest.mark.parametrize( + ("allowed", "legacy_event"), + [ + (True, Events.TOOL_USE_GRANTED_IN_PROMPT), + (False, Events.TOOL_USE_REJECTED_IN_PROMPT), + ], + ) + async def test_permission_request_does_not_emit_legacy_prompt_telemetry( + self, + renderer, + monkeypatch, + allowed, + legacy_event, + ): + logged_events = [] + future = asyncio.get_running_loop().create_future() + permission_event = PermissionRequestEvent( + tool_name="write_file", + tool_input={"path": "main.tf"}, + tool_use_id="tool1", + response_future=future, + ) + + async def events(): + yield permission_event + + async def permission_handler(event): + assert event is permission_event + return allowed + + monkeypatch.setattr( + "iac_code.ui.renderer.log_event", + lambda event_name, payload=None: logged_events.append((event_name, payload)), + raising=False, + ) + + await renderer.run_streaming_output(events(), permission_handler=permission_handler) + + assert future.result() is allowed + assert legacy_event not in [event_name for event_name, _payload in logged_events] + def _make_renderer_for_thinking_test(): from io import StringIO diff --git a/tests/ui/test_renderer_helpers.py b/tests/ui/test_renderer_helpers.py index f172cca8..a26990eb 100644 --- a/tests/ui/test_renderer_helpers.py +++ b/tests/ui/test_renderer_helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from io import StringIO from unittest.mock import MagicMock, patch @@ -10,7 +11,7 @@ from iac_code.pipeline.engine.cleanup import CLEANUP_PROMPT_METADATA_TYPE from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult from iac_code.tools.read_file import ReadFileTool -from iac_code.types.stream_events import StackInstancesProgressEvent, StackProgressEvent +from iac_code.types.stream_events import PermissionRequestEvent, StackInstancesProgressEvent, StackProgressEvent from iac_code.ui.core.key_event import KeyEvent from iac_code.ui.renderer import ( RenderedTurn, @@ -385,9 +386,15 @@ def test_show_transcript_constructs_view_with_current_segments(self): fake_view.run.assert_called_once_with() @pytest.mark.asyncio - async def test_prompt_permission_allow_once(self): + async def test_prompt_permission_allow_once(self, monkeypatch, tmp_path): + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) renderer = make_renderer() - event = MagicMock(tool_name="demo", tool_input={"path": "a.txt"}) + event = PermissionRequestEvent( + tool_name="demo", + tool_input={"path": "a.txt"}, + tool_use_id="toolu-demo", + response_future=asyncio.get_running_loop().create_future(), + ) with patch("iac_code.ui.components.select.Select.run", return_value="allow_once"): allowed = await renderer.prompt_permission(event) diff --git a/tests/ui/test_repl_parallel_auto_approve.py b/tests/ui/test_repl_parallel_auto_approve.py index 2ec2829c..40c22b32 100644 --- a/tests/ui/test_repl_parallel_auto_approve.py +++ b/tests/ui/test_repl_parallel_auto_approve.py @@ -75,6 +75,45 @@ async def _stream_permission_request(response_future: asyncio.Future): ) +async def _stream_nested_permission_request(response_future: asyncio.Future): + from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType + from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent + + sub_id = "sub_nested_inner" + yield PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=time.time(), + data={ + "sub_pipeline_id": sub_id, + "candidate_index": 0, + "candidate_name": "方案1", + "total_steps": 1, + "sub_pipeline_name": "test", + }, + ) + yield SubPipelineStreamEvent( + sub_pipeline_id="sub_nested_outer", + candidate_index=1, + inner=SubPipelineStreamEvent( + sub_pipeline_id=sub_id, + candidate_index=0, + inner=PermissionRequestEvent( + tool_name="bash", + tool_input={}, + tool_use_id="t_nested", + response_future=response_future, + ), + ), + ) + yield PipelineEvent( + type=PipelineEventType.STEP_COMPLETED, + step_id=None, + timestamp=time.time(), + data={}, + ) + + def _make_repl(prompt_result: bool): from iac_code.ui.repl import InlineREPL @@ -129,6 +168,20 @@ async def test_parallel_permission_request_prompts_and_allows(): assert "auto-approved" not in _console_text(repl) +@pytest.mark.asyncio +async def test_parallel_nested_permission_request_prompts_and_allows(): + repl = _make_repl(prompt_result=True) + future = asyncio.get_running_loop().create_future() + + interrupted = await repl._render_parallel_tabs(_stream_nested_permission_request(future)) + + assert interrupted is False + repl.renderer.prompt_permission.assert_awaited_once() + assert future.done() + assert future.result() is True + assert "auto-approved" not in _console_text(repl) + + @pytest.mark.asyncio async def test_parallel_permission_request_prompts_and_denies(): repl = _make_repl(prompt_result=False) diff --git a/tests/ui/test_repl_shell_escape.py b/tests/ui/test_repl_shell_escape.py index e1b84179..674379dc 100644 --- a/tests/ui/test_repl_shell_escape.py +++ b/tests/ui/test_repl_shell_escape.py @@ -6,8 +6,15 @@ import pytest +from iac_code.services.permissions.audit import fingerprint_text from iac_code.tools.base import Tool, ToolResult -from iac_code.types.permissions import PermissionResult, ToolPermissionContext +from iac_code.tools.bash.bash_tool import BashTool +from iac_code.types.permissions import ( + PermissionAuditMetadata, + PermissionAuditSettings, + PermissionResult, + ToolPermissionContext, +) from iac_code.ui.core.input_history import InputHistory from iac_code.ui.repl import InlineREPL @@ -208,16 +215,234 @@ async def test_shell_escape_permission_deny_does_not_execute(tmp_path): assert repl.renderer.messages == [("blocked", "red")] +@pytest.mark.asyncio +async def test_shell_escape_permission_allow_is_audited_without_raw_command(tmp_path, monkeypatch): + audit_records = [] + tool = FakeBashTool( + ToolResult.success("STDOUT:\nok\nExit code: 0"), + PermissionResult( + behavior="allow", + audit=PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + reason_type="rule", + is_read_only=False, + ), + ), + ) + repl = make_repl(tool, str(tmp_path), permission_context=ToolPermissionContext(cwd=str(tmp_path))) + monkeypatch.setattr( + "iac_code.ui.repl.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + raising=False, + ) + + await repl._handle_shell_escape("!echo secret-value") + + assert tool.calls == [({"command": "echo secret-value"}, str(tmp_path))] + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "permission_pipeline" + assert record.scope == "settings_rule" + assert record.decision == "allow" + assert record.operation == {"is_read_only": False} + assert "echo secret-value" not in str(record.input_summary) + + +@pytest.mark.asyncio +async def test_shell_escape_permission_allow_denies_when_audit_log_write_fails(tmp_path, monkeypatch): + tool = FakeBashTool( + ToolResult.success("unused"), + PermissionResult( + behavior="allow", + audit=PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + reason_type="rule", + is_read_only=False, + ), + ), + ) + repl = make_repl(tool, str(tmp_path), permission_context=ToolPermissionContext(cwd=str(tmp_path))) + monkeypatch.setattr("iac_code.ui.repl.emit_permission_audit", lambda record, settings=None: False, raising=False) + + await repl._handle_shell_escape("!mkdir blocked") + + assert tool.calls == [] + assert repl.renderer.messages == [("Permission denied.", "red")] + + +@pytest.mark.asyncio +async def test_shell_escape_permission_denial_is_audited(tmp_path, monkeypatch): + audit_records = [] + tool = FakeBashTool( + ToolResult.success("unused"), + PermissionResult( + behavior="deny", + message="blocked", + audit=PermissionAuditMetadata( + scope="settings_rule", + source="permission_pipeline", + rule_source="project_settings", + reason_type="rule", + is_read_only=False, + ), + ), + ) + repl = make_repl(tool, str(tmp_path), permission_context=ToolPermissionContext(cwd=str(tmp_path))) + monkeypatch.setattr( + "iac_code.ui.repl.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + raising=False, + ) + + await repl._handle_shell_escape("!mkdir blocked") + + assert tool.calls == [] + assert repl.renderer.messages == [("blocked", "red")] + assert len(audit_records) == 1 + record = audit_records[0] + assert record.source == "permission_pipeline" + assert record.scope == "settings_rule" + assert record.decision == "deny" + assert record.tool_name == "bash" + + +@pytest.mark.asyncio +async def test_shell_escape_read_only_permission_denial_is_audited(tmp_path, monkeypatch): + audit_records = [] + tool = FakeBashTool( + ToolResult.success("unused"), + PermissionResult( + behavior="deny", + message="blocked", + audit=PermissionAuditMetadata( + scope="read_only", + source="permission_pipeline", + reason_type="read_only", + is_read_only=True, + ), + ), + ) + repl = make_repl(tool, str(tmp_path), permission_context=ToolPermissionContext(cwd=str(tmp_path))) + monkeypatch.setattr( + "iac_code.ui.repl.emit_permission_audit", + lambda record, settings=None: audit_records.append(record), + raising=False, + ) + + await repl._handle_shell_escape("!cat blocked") + + assert tool.calls == [] + assert repl.renderer.messages == [("blocked", "red")] + assert len(audit_records) == 1 + assert audit_records[0].decision == "deny" + assert audit_records[0].scope == "read_only" + + +@pytest.mark.asyncio +async def test_shell_escape_bash_internal_allow_rule_emits_audit(tmp_path, monkeypatch): + audit_records = [] + settings_seen = [] + tool = BashTool() + permission_context = ToolPermissionContext( + cwd=str(tmp_path), + allow_rules={"session": ["bash(mkdir:*)"]}, + audit_settings=PermissionAuditSettings(include_tool_input=True), + ) + repl = make_repl(tool, str(tmp_path), permission_context=permission_context) + + async def fake_execute_batch(self, calls, context): + return [ToolResult.success("Exit code: 0")] + + monkeypatch.setattr("iac_code.tools.tool_executor.ToolExecutor.execute_batch", fake_execute_batch) + monkeypatch.setattr( + "iac_code.ui.repl.emit_permission_audit", + lambda record, settings=None: (audit_records.append(record), settings_seen.append(settings)), + raising=False, + ) + + await repl._handle_shell_escape("!mkdir foo") + + assert len(audit_records) == 1 + record = audit_records[0] + assert record.decision == "allow" + assert record.scope == "session_rule" + assert record.source == "permission_pipeline" + assert record.rule_source == "session" + assert record.rule == "bash(mkdir:*)" + assert record.operation == {"is_read_only": False} + assert record.tool_input_redacted == { + "command": {"type": "str", "length": 9, "fingerprint": fingerprint_text("mkdir foo")} + } + assert settings_seen == [permission_context.audit_settings] + + +@pytest.mark.asyncio +async def test_shell_escape_bash_internal_deny_rule_emits_audit(tmp_path, monkeypatch): + audit_records = [] + settings_seen = [] + tool = BashTool() + permission_context = ToolPermissionContext( + cwd=str(tmp_path), + deny_rules={"session": ["bash(mkdir:*)"]}, + audit_settings=PermissionAuditSettings(include_tool_input=True), + ) + repl = make_repl(tool, str(tmp_path), permission_context=permission_context) + monkeypatch.setattr( + "iac_code.ui.repl.emit_permission_audit", + lambda record, settings=None: (audit_records.append(record), settings_seen.append(settings)), + raising=False, + ) + + await repl._handle_shell_escape("!mkdir blocked") + + assert len(audit_records) == 1 + record = audit_records[0] + assert record.decision == "deny" + assert record.scope == "session_rule" + assert record.source == "permission_pipeline" + assert record.rule_source == "session" + assert record.rule == "bash(mkdir:*)" + assert record.operation == {"is_read_only": False} + assert record.tool_input_redacted == { + "command": {"type": "str", "length": 13, "fingerprint": fingerprint_text("mkdir blocked")} + } + assert settings_seen == [permission_context.audit_settings] + + @pytest.mark.asyncio async def test_shell_escape_permission_prompt_rejection_does_not_execute(tmp_path): - tool = FakeBashTool(ToolResult.success("unused"), PermissionResult(behavior="ask", message="confirm")) - repl = make_repl(tool, str(tmp_path), permission_allowed=False) + settings = PermissionAuditSettings(include_tool_input=True) + metadata = PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="untrusted_write", + is_read_only=False, + ) + tool = FakeBashTool( + ToolResult.success("unused"), + PermissionResult(behavior="ask", message="confirm", audit=metadata), + ) + repl = make_repl( + tool, + str(tmp_path), + permission_context=ToolPermissionContext(cwd=str(tmp_path), audit_settings=settings), + permission_allowed=False, + ) await repl._handle_shell_escape("!mkdir maybe") assert tool.calls == [] assert repl.renderer.messages == [("Permission denied.", "red")] assert [event.tool_input for event in repl.renderer.permission_events] == [{"command": "mkdir maybe"}] + assert repl.renderer.permission_events[0].audit_context == { + "session_id": "", + "settings": settings, + "metadata": metadata, + } @pytest.mark.asyncio diff --git a/tests/utils/test_state_io.py b/tests/utils/test_state_io.py index af5511bf..efe212f5 100644 --- a/tests/utils/test_state_io.py +++ b/tests/utils/test_state_io.py @@ -3,6 +3,7 @@ import errno import gc import json +import logging import os import sys import types @@ -60,6 +61,67 @@ def test_append_jsonl_locked_writes_one_complete_line_per_record(tmp_path: Path) assert [json.loads(line) for line in lines] == [{"a": 1}, {"b": 2}] +def test_append_jsonl_rotating_locked_rotates_before_append(tmp_path: Path) -> None: + from iac_code.utils.state_io import append_jsonl_rotating_locked + + path = tmp_path / "permission-audit.jsonl" + path.write_text('{"old":true}\n', encoding="utf-8") + + append_jsonl_rotating_locked(path, [{"created": True}], max_file_bytes=10, max_files=2, durable=False) + + assert json.loads(path.read_text(encoding="utf-8")) == {"created": True} + assert (tmp_path / "permission-audit.jsonl.1").exists() + + +def test_append_jsonl_rotating_locked_rotates_when_pending_record_would_exceed_limit(tmp_path: Path) -> None: + from iac_code.utils.state_io import append_jsonl_rotating_locked + + path = tmp_path / "permission-audit.jsonl" + path.write_text('{"old":true}\n', encoding="utf-8") + + append_jsonl_rotating_locked(path, [{"created": True}], max_file_bytes=25, max_files=2, durable=False) + + assert json.loads(path.read_text(encoding="utf-8")) == {"created": True} + assert json.loads((tmp_path / "permission-audit.jsonl.1").read_text(encoding="utf-8")) == {"old": True} + + +def test_append_jsonl_rotating_locked_enforces_retention(tmp_path: Path) -> None: + from iac_code.utils.state_io import append_jsonl_rotating_locked + + path = tmp_path / "permission-audit.jsonl" + for index in range(4): + path.write_text("x" * 20, encoding="utf-8") + append_jsonl_rotating_locked(path, [{"index": index}], max_file_bytes=10, max_files=2, durable=False) + + assert (tmp_path / "permission-audit.jsonl.1").exists() + assert (tmp_path / "permission-audit.jsonl.2").exists() + assert not (tmp_path / "permission-audit.jsonl.3").exists() + + +def test_append_jsonl_rotating_locked_writes_when_rotation_fails_best_effort( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + from iac_code.utils import state_io + + path = tmp_path / "permission-audit.jsonl" + path.write_text('{"old":true}\n', encoding="utf-8") + + def fail_rotation(*args: object, **kwargs: object) -> None: + raise OSError("rename denied") + + monkeypatch.setattr(state_io, "_rotate_jsonl_files", fail_rotation) + + with caplog.at_level(logging.WARNING, logger="iac_code.utils.state_io"): + state_io.append_jsonl_rotating_locked(path, [{"created": True}], max_file_bytes=10, max_files=2) + + lines = path.read_text(encoding="utf-8").splitlines() + assert json.loads(lines[0]) == {"old": True} + assert json.loads(lines[1]) == {"created": True} + assert "Could not rotate JSONL file" in caplog.text + + def test_path_lock_registry_reuses_held_lock_for_same_path(tmp_path: Path) -> None: from iac_code.utils.path_locks import PathLockRegistry diff --git a/website/docs/a2a/command-reference.md b/website/docs/a2a/command-reference.md index 46f2d20b..ec3618bf 100644 --- a/website/docs/a2a/command-reference.md +++ b/website/docs/a2a/command-reference.md @@ -262,7 +262,7 @@ thinking-exposure: |--------|---------|-------------| | `auto-approve-permissions` | `false` | Automatically approve tool permission requests raised during A2A turns | -Without `auto-approve-permissions: true`, A2A mode rejects permission prompts and emits permission metadata. Use it only for trusted automation environments. +Without `auto-approve-permissions: true`, A2A mode rejects permission prompts and emits permission metadata. With it enabled, permission decisions are written to the local permission audit log; any allow decision that requires an audit record fails closed if that record cannot be persisted. Protected Alibaba Cloud write APIs are not blanket-approved by ordinary allow rules; configure exact `aliyun_api(product:action)` allow rules for trusted automation. ## `iac-code a2a-client call` diff --git a/website/docs/a2a/http-transport.md b/website/docs/a2a/http-transport.md index e82c1eae..946e5d87 100644 --- a/website/docs/a2a/http-transport.md +++ b/website/docs/a2a/http-transport.md @@ -264,6 +264,6 @@ For the full option list, see [Command Reference](./command-reference.md). - Bind to `127.0.0.1` for local-only usage. - Use `token` in the A2A config or `IACCODE_A2A_HTTP_TOKEN` before binding to a shared network interface. -- A2A mode rejects tool permission requests automatically; protect unauthenticated endpoints like local automation services. +- A2A mode rejects tool permission requests automatically unless `auto-approve-permissions` or an explicit permission rule allows them. Permission decisions are audited locally; any allow decision that requires an audit record fails closed if that record cannot be persisted. Protected Alibaba Cloud write APIs require exact per-API authorization outside blanket bypass modes. - Active runtime state is in memory. Persistence mirrors task and context metadata, but restarting the process does not resume in-flight asyncio work. - One context can run only one task at a time; separate contexts can run concurrently. diff --git a/website/docs/a2a/overview.md b/website/docs/a2a/overview.md index 20ee6188..27769866 100644 --- a/website/docs/a2a/overview.md +++ b/website/docs/a2a/overview.md @@ -71,4 +71,4 @@ iac-code supports A2A server mode over HTTP JSON-RPC/REST and several optional t - No autonomous planner DAG or complex multi-agent orchestration. - Push delivery is at-least-once for Redis-backed queues; callback receivers must handle duplicates and enforce their own endpoint-side authorization policy. -Tool permission requests are rejected automatically in A2A server mode. Run unauthenticated A2A mode only in trusted local environments or protect it with Bearer token, Basic auth, or API key authentication. +Tool permission requests are rejected automatically in A2A server mode unless `auto-approve-permissions` or an explicit permission rule allows them. Permission decisions are audited locally; any allow decision that requires an audit record fails closed if that record cannot be persisted. Protected Alibaba Cloud write APIs still require exact per-API authorization outside blanket bypass modes. Run unauthenticated A2A mode only in trusted local environments or protect it with Bearer token, Basic auth, or API key authentication. diff --git a/website/docs/a2a/protocol-reference.md b/website/docs/a2a/protocol-reference.md index bb1fbb84..2bcffe34 100644 --- a/website/docs/a2a/protocol-reference.md +++ b/website/docs/a2a/protocol-reference.md @@ -383,10 +383,14 @@ Tool and usage details are delivered through `metadata.iac_code`: | `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed`, or `failed` | | `iac_code.tool.toolUseId` | Stable tool-use ID for correlating tool events | | `iac_code.tool.name` | Tool name when available | -| `iac_code.tool.input` | Completed tool input, truncated to 4000 characters per field | +| `iac_code.tool.inputSummary` | Shape-only summary for completed tool input; string values and non-whitelisted field names are represented without raw business payloads | | `iac_code.tool.result` | Tool result, truncated to 4000 characters per field | | `iac_code.iacCodeSessionId` | Internal iac-code execution session ID for correlating logs and local session artifacts | -| `iac_code.permission.autoApproved` | `false` when a tool permission request was rejected by A2A server mode | +| `iac_code.permission.autoApproved` | Whether A2A approved the permission request automatically or through a resolver | +| `iac_code.permission.toolName` | Tool that requested permission | +| `iac_code.permission.safeSummary` | Redacted human-readable permission summary | +| `iac_code.permission.inputSummary` | Safe operation summary for `aliyun_api` permission requests | +| `iac_code.permission.toolInput` | Shape-only tool input for non-`aliyun_api` permission requests; string values use type, length, and fingerprint, and non-whitelisted field names may be fingerprinted | | `iac_code.thinking.type` | `raw_thinking` when `raw-thinking` is enabled in `thinking-exposure` | | `iac_code.thinking.text` | Raw provider reasoning chunk, truncated to 4000 characters, emitted only for trusted configurations that enable `raw-thinking` | | `iac_code.usage.inputTokens` | Input token count for the turn | diff --git a/website/docs/acp/protocol-reference.md b/website/docs/acp/protocol-reference.md index a9781645..8ad08bca 100644 --- a/website/docs/acp/protocol-reference.md +++ b/website/docs/acp/protocol-reference.md @@ -304,7 +304,7 @@ Each `session/update` notification carries an update object with a specific type ``` ToolCallStart (status=in_progress) │ - ├── ToolCallProgress (status=in_progress, raw_input=tool input) + ├── ToolCallProgress (status=in_progress, input summary / safe prompt input) │ ├── ToolCallProgress (status=completed, raw_output=result) ← success │ @@ -333,8 +333,10 @@ Before executing high-risk tools, iac-code sends a `request_permission` callback | Category | Tools | Auto-allowed | |----------|-------|-------------| | Read-only | `read_file`, `list_files`, `glob`, `grep`, `web_fetch` | Yes | +| Read-only cloud API | `aliyun_api` actions classified as read-only | Yes | | Write | `write_file`, `edit_file` | No — requires approval | | Execute | `bash`, `agent` | No — requires approval | +| Cloud write API | Non-read-only `aliyun_api` calls | No — requires per-API approval | ### request_permission Event @@ -351,12 +353,14 @@ The server sends a `request_permission` callback with: | Option ID | Meaning | |-----------|---------| | `allow_once` | Allow this specific invocation | -| `allow_always` | Allow all future calls of this tool in this session when the tool supports blanket allow; not offered for `bash` by default | +| `allow_always` | Allow all future calls of this tool in this session when the tool supports blanket allow; not offered for `bash` or `aliyun_api` by default | | `allow_rule:` | Allow future calls matching the suggested rule(s) in this session | | `deny_rule:` | Deny future calls matching the suggested rule(s) in this session | | `reject_once` | Deny this specific invocation | | `reject_always` | Deny all future calls of this tool in this session | +For `aliyun_api`, read-only actions are auto-allowed. Non-read-only RPC and ROA actions can offer an exact rule such as `aliyun_api(ros:CreateStack)` or `aliyun_api(cs:CreateCluster)`. Wildcard allow rules still do not approve non-read-only calls. + ### Response Format ```json diff --git a/website/docs/automation/non-interactive-mode.md b/website/docs/automation/non-interactive-mode.md index bd42da69..a2481d63 100644 --- a/website/docs/automation/non-interactive-mode.md +++ b/website/docs/automation/non-interactive-mode.md @@ -47,6 +47,14 @@ When running non-interactively, use `--permission-mode` to control how the agent iac-code --prompt "Deploy the stack" --permission-mode bypass_permissions ``` +In `bypass_permissions`, tool actions are auto-approved except safety checks, but every allow decision that requires an audit record still fails closed if audit persistence fails. Alibaba Cloud write APIs are still protected outside `bypass_permissions`: for narrower trusted automation, stay outside `bypass_permissions` and allow each required write API explicitly: + +```bash +iac-code --prompt "Deploy the stack" \ + --allowed-tools 'aliyun_api(ros:CreateStack)' \ + --permission-mode dont_ask +``` + To restrict what the agent can do, combine `--allowed-tools` and `--disallowed-tools`: ```bash diff --git a/website/docs/cli/command-line-options.md b/website/docs/cli/command-line-options.md index f5bf6263..b5526cc3 100644 --- a/website/docs/cli/command-line-options.md +++ b/website/docs/cli/command-line-options.md @@ -30,9 +30,11 @@ The `--permission-mode` flag controls how the agent handles tool permission chec |---|---| | `default` | The agent prompts for confirmation when a tool action requires approval. | | `accept_edits` | Auto-approve file system commands that are considered edits (e.g. `mkdir`, `cp`). Other actions still prompt. | -| `bypass_permissions` | Auto-approve all tool actions except safety checks. Intended for trusted automation. | +| `bypass_permissions` | Auto-approve tool actions except safety checks. Any allow decision that requires an audit record fails closed if audit persistence fails. Intended for trusted automation. | | `dont_ask` | Silently deny any action that would normally prompt. Useful for strict read-only runs. | +Alibaba Cloud write API calls made through `aliyun_api` are protected separately: a bare `aliyun_api` allow rule does not blanket-approve them, and outside `bypass_permissions` write allow rules must match the exact canonical `product:action` pair. `bypass_permissions` does auto-approve them, but like other audited allow decisions, the action is denied if the permission audit record cannot be persisted. Use exact rules such as `aliyun_api(ros:CreateStack)` when trusted automation should allow only a specific write API. + ## Common Startup Commands Start the interactive REPL with the saved model: @@ -71,6 +73,12 @@ Allow only git and read-only bash commands: iac-code --allowed-tools 'bash(git *)' ``` +Allow one specific Alibaba Cloud write API: + +```bash +iac-code --allowed-tools 'aliyun_api(ros:CreateStack)' +``` + Run in automation with no interactive prompts: ```bash diff --git a/website/docs/configuration/environment-variables.md b/website/docs/configuration/environment-variables.md index 3603b433..7ef10eb8 100644 --- a/website/docs/configuration/environment-variables.md +++ b/website/docs/configuration/environment-variables.md @@ -52,7 +52,8 @@ See [Alibaba Cloud Credentials](./alibaba-cloud-credentials.md) for more details | Variable | Description | |---|---| | `IAC_CODE_CONFIG_DIR` | Override the runtime configuration directory (default `~/.iac-code/`); supports `~` and `$VAR` expansion. All persisted artifacts (credentials, settings, history, projects, image cache, skills, telemetry, etc.) follow it | -| `IAC_CODE_LOG_DIR` | Override the local log directory (default `/logs/`); supports `~` and `$VAR` expansion. Use this to keep startup/debug logs outside `IAC_CODE_CONFIG_DIR` when that directory is mounted or replaced at runtime | +| `IAC_CODE_LOG_DIR` | Override the local startup/debug log directory (default `/logs/`); supports `~` and `$VAR` expansion. Permission audit records still stay under `/logs/permission-audit.jsonl` | +| `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Override `permissions.audit.include_tool_input`; set to `1` / `true` / `yes` / `on` to include shape-only tool input in permission audit records, using type/length/fingerprint instead of raw business payload strings and fingerprinting non-whitelisted field names | | `IAC_CODE_ENV` | Deployment environment label (default: `production`) | | `IAC_CODE_TENANT_ID` | Tenant identifier for telemetry; auto-prefixed with `iac_tenant_` if not already | | `IAC_CODE_GIT_BASH_PATH` | Path to Git Bash `bash.exe` on Windows when it is not on PATH | diff --git a/website/docs/configuration/runtime-configuration.md b/website/docs/configuration/runtime-configuration.md index 624d9943..478b0be7 100644 --- a/website/docs/configuration/runtime-configuration.md +++ b/website/docs/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ The runtime directory defaults to: ~/.iac-code/ ``` -You can relocate it by setting the `IAC_CODE_CONFIG_DIR` environment variable (supports `~` and `$VAR` expansion). When set, every persisted artifact — credentials, settings, history, `projects/`, `image-cache/`, `tool-results/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — follows the new location. Logs default to `/logs/` but can be moved separately with `IAC_CODE_LOG_DIR`. +You can relocate it by setting the `IAC_CODE_CONFIG_DIR` environment variable (supports `~` and `$VAR` expansion). When set, every persisted artifact — credentials, settings, history, `projects/`, `image-cache/`, `tool-results/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — follows the new location. Startup/debug logs default to `/logs/` but can be moved separately with `IAC_CODE_LOG_DIR`; permission audit records stay under `/logs/`. Common files: @@ -106,6 +106,10 @@ permissions: - "bash(curl:*)" additional_directories: - "/tmp/workspace" + audit: + include_tool_input: false + max_file_bytes: 10485760 + max_files: 5 ``` | Field | Description | @@ -115,6 +119,7 @@ permissions: | `deny` | List of tool permission patterns to auto-deny. | | `ask` | List of tool permission patterns that always require confirmation. | | `additional_directories` | Extra directories beyond cwd that the agent is allowed to write to. | +| `audit` | Local permission audit log settings. | ### Pattern Syntax @@ -126,5 +131,40 @@ Tool permission patterns follow the format `tool_name(rule)`: | `bash(git *)` | Match bash commands starting with `git`. | | `bash(curl:*)` | Match bash commands starting with `curl`. | | `write_file` | Match all write_file tool calls. | +| `aliyun_api(ros:CreateStack)` | Match one Alibaba Cloud API product/action pair. | Rules are evaluated in order: **deny → ask → allow → default behavior**. CLI arguments (`--allowed-tools`, `--disallowed-tools`) take the highest precedence. + +### Alibaba Cloud API Permissions + +`aliyun_api` distinguishes read-only API calls from calls that may modify cloud resources. Read-only API actions are allowed automatically. Non-read-only API calls require confirmation or an exact allow rule for that product/action, for example: + +```yaml +permissions: + allow: + - "aliyun_api(ros:CreateStack)" +``` + +A bare `aliyun_api` allow rule does not blanket-approve Alibaba Cloud write APIs. Outside `bypass_permissions`, write allow rules must match the exact canonical `product:action` pair. In `bypass_permissions` mode, protected Alibaba Cloud write APIs are auto-approved, but every allow decision that requires an audit record still fails closed if audit persistence fails. Wildcards can still be useful for deny or ask rules, and for read-only rule matching. + +ROA-style requests are treated as read-only only when the method is `GET` and the request has no body. Non-read-only ROA requests follow the same exact canonical `product:action` allow-rule requirement as RPC-style write APIs: an exact rule such as `aliyun_api(cs:CreateCluster)` can approve the write, while wildcard allow rules still do not approve non-read-only calls. + +### Permission Audit Log + +Permission decisions that cross user prompts, tool-cache boundaries, automation approval, or resolver approval are appended to: + +```text +/logs/permission-audit.jsonl +``` + +By default, this is `~/.iac-code/logs/permission-audit.jsonl`. The permission audit log follows `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` only moves startup/debug logs. The audit writer appends JSONL records with file locking, rotates the file, and restricts local file permissions where the operating system supports it. Routine read-only auto-allow decisions may be omitted, but denials, prompts, cached decisions, automation approvals, resolver approvals, and other audited permission boundaries are recorded. + +Audit settings are configured under `permissions.audit`: + +| Field | Default | Description | +|---|---:|---| +| `include_tool_input` | `false` | Include shape-only tool input in JSONL audit records. String values are stored as type, length, and fingerprint; secret-looking keys are redacted; non-whitelisted field names may be fingerprinted; raw business payload strings are not written. Alibaba Cloud API entries also keep a safe operation summary. | +| `max_file_bytes` | `10485760` | Rotate `permission-audit.jsonl` when it grows past this size. | +| `max_files` | `5` | Number of rotated audit files to keep. Values above the built-in maximum are clamped. | + +If any allow decision that requires an audit record cannot be persisted to the audit log, IaC Code fails closed and denies the action instead of executing it without an audit trail. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 7f9d39dc..5b41bd01 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -258,7 +258,7 @@ thinking-exposure: |--------|---------|-------------| | `auto-approve-permissions` | `false` | Tool-Berechtigungsanfragen, die waehrend A2A-Turns entstehen, automatisch genehmigen | -Ohne `auto-approve-permissions: true` lehnt der A2A-Modus Berechtigungsabfragen ab und gibt Berechtigungsmetadaten aus. Verwenden Sie es nur fuer vertrauenswuerdige Automatisierungsumgebungen. +Ohne `auto-approve-permissions: true` lehnt der A2A-Modus Berechtigungsabfragen ab und gibt Berechtigungsmetadaten aus. Wenn es aktiviert ist, werden Berechtigungsentscheidungen in das lokale Berechtigungsauditprotokoll geschrieben; jede Allow-Entscheidung, die einen Auditdatensatz erfordert, schlaegt fail-closed fehl, wenn dieser Datensatz nicht persistiert werden kann. Geschuetzte Alibaba-Cloud-Schreib-APIs werden durch gewoehnliche Allow-Regeln nicht pauschal genehmigt; konfigurieren Sie fuer vertrauenswuerdige Automatisierung exakte `aliyun_api(product:action)`-Allow-Regeln. ## `iac-code a2a-client call` diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/http-transport.md index 348a55fb..447c6579 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/http-transport.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -264,6 +264,6 @@ Die vollstaendige Optionsliste finden Sie in der [Befehlsreferenz](./command-ref - Binden Sie fuer rein lokale Nutzung an `127.0.0.1`. - Verwenden Sie `token` in der A2A-Konfiguration oder `IACCODE_A2A_HTTP_TOKEN`, bevor Sie an eine gemeinsam genutzte Netzwerkschnittstelle binden. -- Der A2A-Modus lehnt Tool-Berechtigungsanfragen automatisch ab; schuetzen Sie unauthentifizierte Endpunkte wie lokale Automatisierungsservices. +- Der A2A-Modus lehnt Tool-Berechtigungsanfragen automatisch ab, sofern sie nicht durch `auto-approve-permissions` oder eine explizite Berechtigungsregel erlaubt werden. Berechtigungsentscheidungen werden lokal auditiert; jede Allow-Entscheidung, die einen Auditdatensatz erfordert, schlaegt fail-closed fehl, wenn dieser Datensatz nicht persistiert werden kann. Geschuetzte Alibaba-Cloud-Schreib-APIs erfordern ausserhalb pauschaler Bypass-Modi eine exakte Autorisierung pro API. - Aktiver Laufzeitzustand liegt im Speicher. Persistenz spiegelt Task- und Kontextmetadaten, aber ein Prozessneustart setzt laufende asyncio-Arbeit nicht fort. - Ein Kontext kann jeweils nur einen Task ausfuehren; getrennte Kontexte koennen parallel laufen. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md index 76d7e331..24237af0 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -71,4 +71,4 @@ iac-code unterstuetzt A2A-Servermodus ueber HTTP JSON-RPC/REST und mehrere optio - Kein autonomer Planner-DAG und keine komplexe Multi-Agent-Orchestrierung. - Push-Zustellung ist fuer Redis-gestuetzte Queues mindestens einmal; Callback-Empfaenger muessen Duplikate behandeln und ihre eigene autorisierungsseitige Endpoint-Policy erzwingen. -Tool-Berechtigungsanfragen werden im A2A-Servermodus automatisch abgelehnt. Fuehren Sie den unauthentifizierten A2A-Modus nur in vertrauenswuerdigen lokalen Umgebungen aus oder schuetzen Sie ihn mit Bearer-Token-, Basic-Auth- oder API-Key-Authentifizierung. +Tool-Berechtigungsanfragen werden im A2A-Servermodus automatisch abgelehnt, sofern sie nicht durch `auto-approve-permissions` oder eine explizite Berechtigungsregel erlaubt werden. Berechtigungsentscheidungen werden lokal auditiert; jede Allow-Entscheidung, die einen Auditdatensatz erfordert, schlaegt fail-closed fehl, wenn dieser Datensatz nicht persistiert werden kann. Geschuetzte Alibaba-Cloud-Schreib-APIs erfordern ausserhalb pauschaler Bypass-Modi weiterhin eine exakte Autorisierung pro API. Fuehren Sie den unauthentifizierten A2A-Modus nur in vertrauenswuerdigen lokalen Umgebungen aus oder schuetzen Sie ihn mit Bearer-Token-, Basic-Auth- oder API-Key-Authentifizierung. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 14134f79..9fac3d45 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -363,9 +363,13 @@ Tool- und Nutzungsdetails werden ueber `metadata.iac_code` geliefert: | `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` oder `failed` | | `iac_code.tool.toolUseId` | Stabile Tool-Use-ID zur Korrelation von Tool-Events | | `iac_code.tool.name` | Tool-Name, wenn verfuegbar | -| `iac_code.tool.input` | Abgeschlossene Tool-Eingabe, pro Feld auf 4000 Zeichen gekuerzt | +| `iac_code.tool.inputSummary` | Nur die Form der abgeschlossenen Tool-Eingabe; String-Werte und Feldnamen ausserhalb der Whitelist werden ohne rohe fachliche Payloads dargestellt | | `iac_code.tool.result` | Tool-Ergebnis, pro Feld auf 4000 Zeichen gekuerzt | -| `iac_code.permission.autoApproved` | `false`, wenn eine Tool-Berechtigungsanfrage vom A2A-Servermodus abgelehnt wurde | +| `iac_code.permission.autoApproved` | Ob A2A die Berechtigungsanfrage automatisch oder ueber einen Resolver genehmigt hat | +| `iac_code.permission.toolName` | Tool, das die Berechtigung angefordert hat | +| `iac_code.permission.safeSummary` | Redigierte, menschenlesbare Berechtigungszusammenfassung | +| `iac_code.permission.inputSummary` | Sichere Operationszusammenfassung fuer `aliyun_api`-Berechtigungsanfragen | +| `iac_code.permission.toolInput` | Nur die Form der Tool-Eingabe fuer Berechtigungsanfragen ausserhalb von `aliyun_api`; String-Werte verwenden Typ, Laenge und Fingerprint, und Feldnamen ausserhalb der Whitelist koennen als Fingerprint erscheinen | | `iac_code.thinking.type` | `raw_thinking`, wenn `raw-thinking` in `thinking-exposure` aktiviert ist | | `iac_code.thinking.text` | Roher Provider-Reasoning-Chunk, auf 4000 Zeichen gekuerzt, nur fuer vertrauenswuerdige Konfigurationen ausgegeben, die `raw-thinking` aktivieren | | `iac_code.usage.inputTokens` | Anzahl der Eingabetoken fuer den Turn | diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index f7160f7e..850decfe 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -304,7 +304,7 @@ Jede `session/update`-Benachrichtigung traegt ein Update-Objekt mit einem bestim ``` ToolCallStart (status=in_progress) │ - ├── ToolCallProgress (status=in_progress, raw_input=tool input) + ├── ToolCallProgress (status=in_progress, Eingabezusammenfassung / sichere Prompt-Eingabe) │ ├── ToolCallProgress (status=completed, raw_output=result) ← success │ @@ -333,8 +333,10 @@ Vor der Ausfuehrung risikoreicher Tools sendet iac-code einen `request_permissio | Kategorie | Tools | Automatisch erlaubt | |----------|-------|-------------| | Nur-Lese | `read_file`, `list_files`, `glob`, `grep`, `web_fetch` | Ja | +| Nur-Lese-Cloud-API | Als nur lesend eingestufte `aliyun_api`-Aktionen | Ja | | Schreiben | `write_file`, `edit_file` | Nein -- erfordert Genehmigung | | Ausfuehren | `bash`, `agent` | Nein -- erfordert Genehmigung | +| Cloud-Schreib-API | Nicht nur lesende `aliyun_api`-Aufrufe | Nein -- erfordert Genehmigung pro API | ### request_permission-Ereignis @@ -351,12 +353,14 @@ Der Server sendet einen `request_permission`-Callback mit: | Options-ID | Bedeutung | |-----------|---------| | `allow_once` | Diesen spezifischen Aufruf erlauben | -| `allow_always` | Alle zukuenftigen Aufrufe dieses Tools in dieser Sitzung erlauben, wenn das Tool blanket allow unterstuetzt; fuer `bash` standardmaessig nicht angeboten | +| `allow_always` | Alle zukuenftigen Aufrufe dieses Tools in dieser Sitzung erlauben, wenn das Tool blanket allow unterstuetzt; fuer `bash` und `aliyun_api` standardmaessig nicht angeboten | | `allow_rule:` | Zukuenftige Aufrufe erlauben, die den vorgeschlagenen Regeln in dieser Sitzung entsprechen | | `deny_rule:` | Zukuenftige Aufrufe verweigern, die den vorgeschlagenen Regeln in dieser Sitzung entsprechen | | `reject_once` | Diesen spezifischen Aufruf verweigern | | `reject_always` | Alle zukuenftigen Aufrufe dieses Tools in dieser Sitzung verweigern | +Fuer `aliyun_api` werden Nur-Lese-Aktionen automatisch erlaubt. Nicht nur lesende RPC- und ROA-Aktionen koennen eine exakte Regel wie `aliyun_api(ros:CreateStack)` oder `aliyun_api(cs:CreateCluster)` anbieten. Wildcard-Allow-Regeln genehmigen nicht nur lesende Aufrufe weiterhin nicht. + ### Antwortformat ```json diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 91517463..598c5e52 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -47,6 +47,14 @@ Verwenden Sie beim nicht-interaktiven Ausführen `--permission-mode`, um zu steu iac-code --prompt "Deploy the stack" --permission-mode bypass_permissions ``` +In `bypass_permissions` werden Werkzeugaktionen ausser Sicherheitspruefungen automatisch genehmigt, aber jede Allow-Entscheidung, die einen Auditdatensatz erfordert, schlaegt weiterhin fail-closed fehl, wenn die Auditpersistenz fehlschlaegt. Alibaba-Cloud-Schreib-APIs bleiben ausserhalb von `bypass_permissions` gesondert geschuetzt; fuer enger gefasste vertrauenswuerdige Automatisierung bleiben Sie ausserhalb von `bypass_permissions` und erlauben jede benoetigte Schreib-API explizit: + +```bash +iac-code --prompt "Deploy the stack" \ + --allowed-tools 'aliyun_api(ros:CreateStack)' \ + --permission-mode dont_ask +``` + Um einzuschränken, was der Agent tun kann, kombinieren Sie `--allowed-tools` und `--disallowed-tools`: ```bash diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md index 2b523ab5..7bfd2a63 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -30,9 +30,11 @@ Der Parameter `--permission-mode` steuert, wie der Agent Werkzeug-Berechtigungsp |---|---| | `default` | Der Agent fragt nach Bestätigung, wenn eine Werkzeugaktion eine Genehmigung erfordert. | | `accept_edits` | Dateisystem-Befehle, die als Bearbeitungen gelten (z.B. `mkdir`, `cp`), automatisch genehmigen. Andere Aktionen erfordern weiterhin Bestätigung. | -| `bypass_permissions` | Alle Werkzeugaktionen außer Sicherheitsprüfungen automatisch genehmigen. Für vertrauenswürdige Automatisierung vorgesehen. | +| `bypass_permissions` | Werkzeugaktionen ausser Sicherheitspruefungen automatisch genehmigen. Jede Allow-Entscheidung, die einen Auditdatensatz erfordert, schlaegt fail-closed fehl, wenn die Auditpersistenz fehlschlaegt. Fuer vertrauenswuerdige Automatisierung vorgesehen. | | `dont_ask` | Jede Aktion, die normalerweise eine Bestätigung erfordern würde, stillschweigend ablehnen. Nützlich für strenge Nur-Lese-Läufe. | +Alibaba-Cloud-Schreib-API-Aufrufe ueber `aliyun_api` sind gesondert geschuetzt: Eine blosse `aliyun_api`-Allow-Regel genehmigt sie nicht pauschal, und ausserhalb von `bypass_permissions` muessen Allow-Regeln fuer Schreibzugriffe exakt zum kanonischen `product:action`-Paar passen. `bypass_permissions` genehmigt sie automatisch, aber wie bei anderen auditierten Allow-Entscheidungen wird die Aktion abgelehnt, wenn der Berechtigungsauditdatensatz nicht persistiert werden kann. Verwenden Sie exakte Regeln wie `aliyun_api(ros:CreateStack)`, wenn vertrauenswuerdige Automatisierung nur eine bestimmte Schreib-API erlauben soll. + ## Häufige Startbefehle Die interaktive REPL mit dem gespeicherten Modell starten: @@ -71,6 +73,12 @@ Nur git und schreibgeschützte Bash-Befehle erlauben: iac-code --allowed-tools 'bash(git *)' ``` +Eine bestimmte Alibaba-Cloud-Schreib-API erlauben: + +```bash +iac-code --allowed-tools 'aliyun_api(ros:CreateStack)' +``` + In der Automatisierung ohne interaktive Abfragen ausführen: ```bash diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index da4b7f8b..d91c89ce 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,6 +52,8 @@ Siehe [Alibaba Cloud-Anmeldedaten](./alibaba-cloud-credentials.md) fuer weitere | Variable | Beschreibung | |---|---| | `IAC_CODE_CONFIG_DIR` | Ueberschreibt das Laufzeitkonfigurationsverzeichnis (Standard `~/.iac-code/`); unterstuetzt `~`- und `$VAR`-Erweiterung. Alle persistierten Artefakte (Anmeldedaten, Einstellungen, Verlauf, projects, image-cache, skills, telemetry usw.) folgen diesem Verzeichnis | +| `IAC_CODE_LOG_DIR` | Ueberschreibt das lokale Verzeichnis fuer Start-/Debug-Logs (Standard `/logs/`); unterstuetzt `~`- und `$VAR`-Erweiterung. Berechtigungsauditdatensaetze bleiben unter `/logs/permission-audit.jsonl` | +| `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Ueberschreibt `permissions.audit.include_tool_input`; auf `1` / `true` / `yes` / `on` setzen, um die Form der Tool-Eingabe in Berechtigungsauditdatensaetze aufzunehmen, mit Typ/Laenge/Fingerprint statt roher fachlicher Payload-Strings und mit Fingerprints fuer Feldnamen ausserhalb der Whitelist | | `IAC_CODE_ENV` | Bezeichnung der Bereitstellungsumgebung (Standard: `production`) | | `IAC_CODE_TENANT_ID` | Mandantenkennung fuer Telemetrie; wird automatisch mit `iac_tenant_` vorangestellt, wenn nicht bereits vorhanden | | `IAC_CODE_GIT_BASH_PATH` | Pfad zu Git Bash `bash.exe` unter Windows, wenn nicht im PATH | diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 791ff3e2..91e5886e 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ Das Laufzeitverzeichnis ist standardmäßig: ~/.iac-code/ ``` -Sie können es verlegen, indem Sie die Umgebungsvariable `IAC_CODE_CONFIG_DIR` setzen (unterstützt `~`- und `$VAR`-Erweiterung). Sobald gesetzt, folgen alle persistierten Artefakte — Anmeldedaten, Einstellungen, Verlauf, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — dem neuen Speicherort. +Sie können es verlegen, indem Sie die Umgebungsvariable `IAC_CODE_CONFIG_DIR` setzen (unterstützt `~`- und `$VAR`-Erweiterung). Sobald gesetzt, folgen alle persistierten Artefakte — Anmeldedaten, Einstellungen, Verlauf, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — dem neuen Speicherort. Start-/Debug-Logs liegen standardmaessig unter `/logs/` und koennen separat mit `IAC_CODE_LOG_DIR` verschoben werden; Berechtigungsauditdatensaetze bleiben unter `/logs/`. Häufige Dateien: @@ -106,6 +106,10 @@ permissions: - "bash(curl:*)" additional_directories: - "/tmp/workspace" + audit: + include_tool_input: false + max_file_bytes: 10485760 + max_files: 5 ``` | Feld | Beschreibung | @@ -115,6 +119,7 @@ permissions: | `deny` | Liste der automatisch verweigerten Werkzeug-Berechtigungsmuster. | | `ask` | Liste der Werkzeug-Berechtigungsmuster, die immer eine Bestätigung erfordern. | | `additional_directories` | Zusätzliche Verzeichnisse über cwd hinaus, in die der Agent schreiben darf. | +| `audit` | Lokale Einstellungen fuer das Berechtigungsauditprotokoll. | ### Mustersyntax @@ -126,5 +131,40 @@ Werkzeug-Berechtigungsmuster folgen dem Format `tool_name(rule)`: | `bash(git *)` | Bash-Befehle abgleichen, die mit `git` beginnen. | | `bash(curl:*)` | Bash-Befehle abgleichen, die mit `curl` beginnen. | | `write_file` | Alle write_file-Werkzeugaufrufe abgleichen. | +| `aliyun_api(ros:CreateStack)` | Ein Alibaba-Cloud-API-Produkt/Aktion-Paar abgleichen. | Regeln werden in folgender Reihenfolge ausgewertet: **deny → ask → allow → Standardverhalten**. CLI-Argumente (`--allowed-tools`, `--disallowed-tools`) haben die höchste Priorität. + +### Alibaba-Cloud-API-Berechtigungen + +`aliyun_api` unterscheidet reine Lese-API-Aufrufe von Aufrufen, die Cloud-Ressourcen veraendern koennen. Nur-Lese-API-Aktionen werden automatisch erlaubt. Nicht nur lesende API-Aufrufe erfordern eine Bestaetigung oder eine exakte Allow-Regel fuer das jeweilige Produkt und die jeweilige Aktion, zum Beispiel: + +```yaml +permissions: + allow: + - "aliyun_api(ros:CreateStack)" +``` + +Eine blosse `aliyun_api`-Allow-Regel genehmigt Alibaba-Cloud-Schreib-APIs nicht pauschal. Ausserhalb von `bypass_permissions` muessen Allow-Regeln fuer Schreibzugriffe exakt zum kanonischen `product:action`-Paar passen. Im Modus `bypass_permissions` werden geschuetzte Alibaba-Cloud-Schreib-APIs automatisch genehmigt, aber jede Allow-Entscheidung, die einen Auditdatensatz erfordert, schlaegt weiterhin fail-closed fehl, wenn die Auditpersistenz fehlschlaegt. Wildcards koennen weiterhin fuer deny- oder ask-Regeln sowie fuer Nur-Lese-Regelabgleiche nuetzlich sein. + +ROA-artige Requests gelten nur dann als nur lesend, wenn die Methode `GET` ist und der Request keinen Body hat. Nicht nur lesende ROA-Requests folgen derselben Anforderung an eine exakte kanonische `product:action`-Allow-Regel wie RPC-artige Schreib-APIs: Eine exakte Regel wie `aliyun_api(cs:CreateCluster)` kann den Schreibzugriff genehmigen, waehrend Wildcard-Allow-Regeln nicht nur lesende Aufrufe weiterhin nicht genehmigen. + +### Berechtigungsauditprotokoll + +Berechtigungsentscheidungen, die Benutzerabfragen, Tool-Cache-Grenzen, Automatisierungsgenehmigungen oder Resolver-Genehmigungen ueberschreiten, werden angehaengt an: + +```text +/logs/permission-audit.jsonl +``` + +Standardmaessig ist dies `~/.iac-code/logs/permission-audit.jsonl`. Das Berechtigungsauditlog folgt `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` verschiebt nur Start-/Debug-Logs. Der Audit-Writer haengt JSONL-Datensaetze mit Dateisperren an, rotiert die Datei und schraenkt lokale Dateiberechtigungen ein, soweit das Betriebssystem dies unterstuetzt. Routinemaessige automatische Nur-Lese-Genehmigungen koennen ausgelassen werden, aber Ablehnungen, Prompts, gecachte Entscheidungen, Automatisierungsgenehmigungen, Resolver-Genehmigungen und andere auditierte Berechtigungsgrenzen werden aufgezeichnet. + +Audit-Einstellungen werden unter `permissions.audit` konfiguriert: + +| Feld | Standard | Beschreibung | +|---|---:|---| +| `include_tool_input` | `false` | Nur die Form der Tool-Eingabe in JSONL-Auditdatensaetze aufnehmen. String-Werte werden als Typ, Laenge und Fingerprint gespeichert; geheimnisverdaechtige Schluessel werden redigiert; Feldnamen ausserhalb der Whitelist koennen als Fingerprint erscheinen; rohe fachliche Payload-Strings werden nicht geschrieben. Alibaba-Cloud-API-Eintraege behalten zusaetzlich eine sichere Operationszusammenfassung. | +| `max_file_bytes` | `10485760` | `permission-audit.jsonl` rotieren, wenn diese Groesse ueberschritten wird. | +| `max_files` | `5` | Anzahl der aufzubewahrenden rotierten Auditdateien. Werte ueber dem eingebauten Maximum werden begrenzt. | + +Wenn eine Allow-Entscheidung, die einen Auditdatensatz erfordert, nicht im Auditprotokoll persistiert werden kann, verhaelt sich IaC Code fail-closed und lehnt die Aktion ab, statt sie ohne Auditspur auszufuehren. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 8cba6846..98845efa 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -258,7 +258,7 @@ thinking-exposure: |--------|---------|-------------| | `auto-approve-permissions` | `false` | Aprobar automáticamente solicitudes de permisos de herramientas generadas durante turnos A2A | -Sin `auto-approve-permissions: true`, el modo A2A rechaza solicitudes de permisos y emite metadatos de permisos. Úsalo solo para entornos de automatización de confianza. +Sin `auto-approve-permissions: true`, el modo A2A rechaza solicitudes de permisos y emite metadatos de permisos. Cuando está habilitado, las decisiones de permisos se escriben en el registro local de auditoría de permisos; toda decisión allow que requiere un registro de auditoría falla en modo cerrado si ese registro no se puede persistir. Las API protegidas de escritura de Alibaba Cloud no se aprueban de forma global con reglas allow ordinarias; configura reglas allow exactas `aliyun_api(product:action)` para automatización confiable. ## `iac-code a2a-client call` diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/http-transport.md index f96e607e..8d2bfcd1 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/http-transport.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -264,6 +264,6 @@ Para la lista completa de opciones, consulta la [referencia de comandos](./comma - Enlaza a `127.0.0.1` para uso solo local. - Usa `token` en la configuración A2A o `IACCODE_A2A_HTTP_TOKEN` antes de enlazar a una interfaz de red compartida. -- El modo A2A rechaza automáticamente las solicitudes de permisos de herramientas; protege los endpoints sin autenticación como servicios de automatización local. +- El modo A2A rechaza automáticamente las solicitudes de permisos de herramientas salvo que `auto-approve-permissions` o una regla de permisos explícita las permita. Las decisiones de permisos se auditan localmente; toda decisión allow que requiere un registro de auditoría falla en modo cerrado si ese registro no se puede persistir. Las API protegidas de escritura de Alibaba Cloud requieren autorización exacta por API fuera de los modos de bypass global. - El estado activo del runtime está en memoria. La persistencia refleja metadatos de tareas y contextos, pero reiniciar el proceso no reanuda trabajo asyncio en curso. - Un contexto solo puede ejecutar una tarea a la vez; los contextos separados pueden ejecutarse de forma concurrente. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md index 5e3cd92d..4fac1cc8 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -71,4 +71,4 @@ iac-code soporta el modo servidor A2A sobre HTTP JSON-RPC/REST y varios transpor - No hay DAG de planificador autónomo ni orquestación multiagente compleja. - La entrega push es al menos una vez para colas respaldadas por Redis; los receptores de callback deben manejar duplicados y aplicar su propia política de autorización del lado del endpoint. -Las solicitudes de permisos de herramientas se rechazan automáticamente en modo servidor A2A. Ejecuta el modo A2A sin autenticación solo en entornos locales de confianza o protégelo con autenticación mediante token Bearer, Basic auth o clave de API. +Las solicitudes de permisos de herramientas se rechazan automáticamente en modo servidor A2A, salvo que `auto-approve-permissions` o una regla de permisos explícita las permita. Las decisiones de permisos se auditan localmente; toda decisión allow que requiere un registro de auditoría falla en modo cerrado si ese registro no se puede persistir. Las API protegidas de escritura de Alibaba Cloud siguen requiriendo autorización exacta por API fuera de los modos de bypass global. Ejecuta el modo A2A sin autenticación solo en entornos locales de confianza o protégelo con autenticación mediante token Bearer, Basic auth o clave de API. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 868ebc4a..e4eacc2b 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -363,9 +363,13 @@ Los detalles de herramientas y uso se entregan mediante `metadata.iac_code`: | `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` o `failed` | | `iac_code.tool.toolUseId` | ID estable de uso de herramienta para correlacionar eventos de herramienta | | `iac_code.tool.name` | Nombre de la herramienta cuando está disponible | -| `iac_code.tool.input` | Entrada completada de la herramienta, truncada a 4000 caracteres por campo | +| `iac_code.tool.inputSummary` | Resumen solo con forma de la entrada completada de la herramienta; los valores de cadena y los nombres de campo fuera de la lista permitida se representan sin payloads de negocio sin procesar | | `iac_code.tool.result` | Resultado de la herramienta, truncado a 4000 caracteres por campo | -| `iac_code.permission.autoApproved` | `false` cuando una solicitud de permiso de herramienta fue rechazada por el modo servidor A2A | +| `iac_code.permission.autoApproved` | Si A2A aprobó la solicitud de permiso automáticamente o mediante un resolver | +| `iac_code.permission.toolName` | Herramienta que solicitó el permiso | +| `iac_code.permission.safeSummary` | Resumen de permisos redactado y legible por humanos | +| `iac_code.permission.inputSummary` | Resumen seguro de operación para solicitudes de permisos de `aliyun_api` | +| `iac_code.permission.toolInput` | Entrada de herramienta solo con forma para solicitudes de permisos que no son de `aliyun_api`; los valores de cadena usan tipo, longitud y huella, y los nombres de campo fuera de la lista permitida pueden representarse con huella | | `iac_code.thinking.type` | `raw_thinking` cuando `raw-thinking` está habilitado en `thinking-exposure` | | `iac_code.thinking.text` | Chunk bruto de razonamiento del provider, truncado a 4000 caracteres, emitido solo para configuraciones de confianza que habilitan `raw-thinking` | | `iac_code.usage.inputTokens` | Recuento de tokens de entrada del turno | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index 0775eec4..ad59c380 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -304,7 +304,7 @@ Cada notificacion `session/update` lleva un objeto de actualizacion con un tipo ``` ToolCallStart (status=in_progress) │ - ├── ToolCallProgress (status=in_progress, raw_input=tool input) + ├── ToolCallProgress (status=in_progress, resumen de entrada / entrada segura para prompt) │ ├── ToolCallProgress (status=completed, raw_output=result) ← success │ @@ -333,8 +333,10 @@ Antes de ejecutar herramientas de alto riesgo, iac-code envia un callback `reque | Categoria | Herramientas | Auto-permitidas | |----------|-------|-------------| | Solo lectura | `read_file`, `list_files`, `glob`, `grep`, `web_fetch` | Si | +| API de nube de solo lectura | Acciones `aliyun_api` clasificadas como de solo lectura | Sí | | Escritura | `write_file`, `edit_file` | No — requiere aprobacion | | Ejecucion | `bash`, `agent` | No — requiere aprobacion | +| API de escritura de nube | Llamadas `aliyun_api` que no son de solo lectura | No — requiere aprobación por API | ### Evento request_permission @@ -351,12 +353,14 @@ El servidor envia un callback `request_permission` con: | ID de opcion | Significado | |-----------|---------| | `allow_once` | Permitir esta invocacion especifica | -| `allow_always` | Permitir todas las futuras llamadas de esta herramienta en esta sesion cuando la herramienta admite permiso global; no se ofrece para `bash` por defecto | +| `allow_always` | Permitir todas las futuras llamadas de esta herramienta en esta sesion cuando la herramienta admite permiso global; no se ofrece para `bash` ni `aliyun_api` por defecto | | `allow_rule:` | Permitir futuras llamadas que coincidan con las reglas sugeridas en esta sesion | | `deny_rule:` | Denegar futuras llamadas que coincidan con las reglas sugeridas en esta sesion | | `reject_once` | Denegar esta invocacion especifica | | `reject_always` | Denegar todas las futuras llamadas de esta herramienta en esta sesion | +Para `aliyun_api`, las acciones de solo lectura se permiten automáticamente. Las acciones RPC y ROA que no son de solo lectura pueden ofrecer una regla exacta como `aliyun_api(ros:CreateStack)` o `aliyun_api(cs:CreateCluster)`. Las reglas allow con comodines siguen sin aprobar llamadas que no son de solo lectura. + ### Formato de respuesta ```json diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index ce5d6ec6..66bd3840 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -47,6 +47,14 @@ Cuando ejecute en modo no interactivo, use `--permission-mode` para controlar c iac-code --prompt "Deploy the stack" --permission-mode bypass_permissions ``` +En `bypass_permissions`, las acciones de herramientas se aprueban automáticamente excepto las comprobaciones de seguridad, pero toda decisión allow que requiere un registro de auditoría sigue fallando en modo cerrado si falla la persistencia de auditoría. Las API de escritura de Alibaba Cloud siguen protegidas por separado fuera de `bypass_permissions`; para una automatización confiable más acotada, no use `bypass_permissions` y permita explícitamente cada API de escritura requerida: + +```bash +iac-code --prompt "Deploy the stack" \ + --allowed-tools 'aliyun_api(ros:CreateStack)' \ + --permission-mode dont_ask +``` + Para restringir lo que el agente puede hacer, combine `--allowed-tools` y `--disallowed-tools`: ```bash diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md index 50594eb9..634918f1 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -30,9 +30,11 @@ El parámetro `--permission-mode` controla cómo el agente maneja las comprobaci |---|---| | `default` | El agente solicita confirmación cuando una acción de herramienta requiere aprobación. | | `accept_edits` | Aprobar automáticamente comandos del sistema de archivos considerados como ediciones (ej. `mkdir`, `cp`). Otras acciones aún solicitan confirmación. | -| `bypass_permissions` | Aprobar automáticamente todas las acciones de herramientas excepto las comprobaciones de seguridad. Destinado para automatización confiable. | +| `bypass_permissions` | Aprobar automáticamente acciones de herramientas excepto comprobaciones de seguridad. Toda decisión allow que requiere un registro de auditoría falla en modo cerrado si falla la persistencia de auditoría. Destinado para automatización confiable. | | `dont_ask` | Denegar silenciosamente cualquier acción que normalmente solicitaría confirmación. Útil para ejecuciones estrictamente de solo lectura. | +Las llamadas a API de escritura de Alibaba Cloud realizadas mediante `aliyun_api` están protegidas por separado: una regla allow simple `aliyun_api` no las aprueba de forma global, y fuera de `bypass_permissions` las reglas allow de escritura deben coincidir exactamente con el par canónico `product:action`. `bypass_permissions` sí las aprueba automáticamente, pero igual que otras decisiones allow auditadas, la acción se deniega si el registro de auditoría de permisos no se puede persistir. Usa reglas exactas como `aliyun_api(ros:CreateStack)` cuando la automatización confiable deba permitir solo una API de escritura específica. + ## Comandos de inicio comunes Iniciar el REPL interactivo con el modelo guardado: @@ -71,6 +73,12 @@ Permitir solo comandos git y bash de solo lectura: iac-code --allowed-tools 'bash(git *)' ``` +Permitir una API de escritura específica de Alibaba Cloud: + +```bash +iac-code --allowed-tools 'aliyun_api(ros:CreateStack)' +``` + Ejecutar en automatización sin prompts interactivos: ```bash diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index 61784c6a..c3777180 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,6 +52,8 @@ Consulta [Credenciales de Alibaba Cloud](./alibaba-cloud-credentials.md) para ma | Variable | Descripcion | |---|---| | `IAC_CODE_CONFIG_DIR` | Sobreescribe el directorio de configuracion en tiempo de ejecucion (predeterminado `~/.iac-code/`); admite expansion de `~` y `$VAR`. Todos los artefactos persistidos (credenciales, ajustes, historial, projects, image-cache, skills, telemetry, etc.) siguen este directorio | +| `IAC_CODE_LOG_DIR` | Sobrescribe el directorio local de logs de arranque/depuración (predeterminado `/logs/`); admite expansión de `~` y `$VAR`. Los registros de auditoría de permisos permanecen en `/logs/permission-audit.jsonl` | +| `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Sobrescribe `permissions.audit.include_tool_input`; establécelo en `1` / `true` / `yes` / `on` para incluir entrada de herramienta solo con forma en los registros de auditoría de permisos, usando tipo/longitud/huella en vez de cadenas de payload de negocio sin procesar y aplicando huellas a nombres de campo fuera de la lista permitida | | `IAC_CODE_ENV` | Etiqueta del entorno de despliegue (predeterminado: `production`) | | `IAC_CODE_TENANT_ID` | Identificador de tenant para telemetria; se le agrega automaticamente el prefijo `iac_tenant_` si no lo tiene | | `IAC_CODE_GIT_BASH_PATH` | Ruta a `bash.exe` de Git Bash en Windows cuando no esta en el PATH | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 13049fae..11358ad3 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ El directorio de tiempo de ejecución por defecto es: ~/.iac-code/ ``` -Puede reubicarlo estableciendo la variable de entorno `IAC_CODE_CONFIG_DIR` (admite expansión de `~` y `$VAR`). Cuando se establece, todos los artefactos persistidos — credenciales, ajustes, historial, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — siguen la nueva ubicación. +Puede reubicarlo estableciendo la variable de entorno `IAC_CODE_CONFIG_DIR` (admite expansión de `~` y `$VAR`). Cuando se establece, todos los artefactos persistidos — credenciales, ajustes, historial, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — siguen la nueva ubicación. Los logs de arranque/depuración van por defecto a `/logs/` y se pueden mover por separado con `IAC_CODE_LOG_DIR`; los registros de auditoría de permisos permanecen en `/logs/`. Archivos comunes: @@ -106,6 +106,10 @@ permissions: - "bash(curl:*)" additional_directories: - "/tmp/workspace" + audit: + include_tool_input: false + max_file_bytes: 10485760 + max_files: 5 ``` | Campo | Descripción | @@ -115,6 +119,7 @@ permissions: | `deny` | Lista de patrones de permisos de herramientas para denegar automáticamente. | | `ask` | Lista de patrones de permisos de herramientas que siempre requieren confirmación. | | `additional_directories` | Directorios adicionales más allá de cwd en los que el agente puede escribir. | +| `audit` | Configuración local del registro de auditoría de permisos. | ### Sintaxis de patrones @@ -126,5 +131,40 @@ Los patrones de permisos de herramientas siguen el formato `tool_name(rule)`: | `bash(git *)` | Coincidir con comandos bash que comienzan con `git`. | | `bash(curl:*)` | Coincidir con comandos bash que comienzan con `curl`. | | `write_file` | Coincidir con todas las llamadas a la herramienta write_file. | +| `aliyun_api(ros:CreateStack)` | Coincidir con un par producto/acción de API de Alibaba Cloud. | Las reglas se evalúan en orden: **deny → ask → allow → comportamiento predeterminado**. Los argumentos CLI (`--allowed-tools`, `--disallowed-tools`) tienen la mayor precedencia. + +### Permisos de API de Alibaba Cloud + +`aliyun_api` distingue entre llamadas API de solo lectura y llamadas que pueden modificar recursos en la nube. Las acciones API de solo lectura se permiten automáticamente. Las llamadas API que no son de solo lectura requieren confirmación o una regla de allow exacta para ese producto/acción, por ejemplo: + +```yaml +permissions: + allow: + - "aliyun_api(ros:CreateStack)" +``` + +Una regla allow simple `aliyun_api` no aprueba de forma global las API de escritura de Alibaba Cloud. Fuera de `bypass_permissions`, las reglas allow de escritura deben coincidir exactamente con el par canónico `product:action`. En modo `bypass_permissions`, las API protegidas de escritura de Alibaba Cloud se aprueban automáticamente, pero toda decisión allow que requiere un registro de auditoría falla en modo cerrado si falla la persistencia de auditoría. Los comodines siguen siendo útiles para reglas deny o ask, y para coincidencias de reglas de solo lectura. + +Las solicitudes de estilo ROA se tratan como de solo lectura únicamente cuando el método es `GET` y la solicitud no tiene body. Las solicitudes ROA que no son de solo lectura siguen el mismo requisito de regla allow canónica exacta `product:action` que las API de escritura de estilo RPC: una regla exacta como `aliyun_api(cs:CreateCluster)` puede aprobar la escritura, mientras que las reglas allow con comodines siguen sin aprobar llamadas que no son de solo lectura. + +### Registro de auditoría de permisos + +Las decisiones de permisos que cruzan prompts de usuario, límites de caché de herramientas, aprobación de automatización o aprobación de un resolver se anexan a: + +```text +/logs/permission-audit.jsonl +``` + +De forma predeterminada, esta ruta es `~/.iac-code/logs/permission-audit.jsonl`. El registro de auditoría de permisos sigue `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` solo mueve los logs de arranque/depuración. El escritor de auditoría anexa registros JSONL con bloqueo de archivo, rota el archivo y restringe los permisos del archivo local cuando el sistema operativo lo permite. Las aprobaciones automáticas rutinarias de solo lectura pueden omitirse, pero se registran denegaciones, prompts, decisiones en caché, aprobaciones de automatización, aprobaciones de resolver y otros límites de permisos auditados. + +La configuración de auditoría se define en `permissions.audit`: + +| Campo | Predeterminado | Descripción | +|---|---:|---| +| `include_tool_input` | `false` | Incluir entrada de herramienta solo con forma en los registros JSONL de auditoría. Los valores de cadena se guardan como tipo, longitud y huella; las claves que parecen secretas se redactan; los nombres de campo fuera de la lista permitida pueden representarse con huella; no se escriben cadenas de payload de negocio sin procesar. Las entradas de API de Alibaba Cloud también conservan un resumen seguro de la operación. | +| `max_file_bytes` | `10485760` | Rotar `permission-audit.jsonl` cuando supere este tamaño. | +| `max_files` | `5` | Número de archivos de auditoría rotados que se conservan. Los valores por encima del máximo integrado se limitan. | + +Si una decisión allow que requiere un registro de auditoría no se puede persistir en el registro de auditoría, IaC Code falla en modo cerrado y deniega la acción en lugar de ejecutarla sin rastro de auditoría. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 1392c458..88f026bb 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -258,7 +258,7 @@ thinking-exposure: |--------|---------|-------------| | `auto-approve-permissions` | `false` | Approuver automatiquement les demandes d'autorisation d'outil levées pendant les tours A2A | -Sans `auto-approve-permissions: true`, le mode A2A rejette les prompts d'autorisation et émet des métadonnées d'autorisation. Utilisez-le seulement pour les environnements d'automatisation de confiance. +Sans `auto-approve-permissions: true`, le mode A2A rejette les prompts d'autorisation et émet des métadonnées d'autorisation. Lorsqu'il est activé, les décisions de permissions sont écrites dans le journal local d'audit des permissions ; toute décision allow nécessitant un enregistrement d'audit échoue en mode fail-closed si cet enregistrement ne peut pas être persisté. Les API d'écriture Alibaba Cloud protégées ne sont pas approuvées globalement par des règles allow ordinaires ; configurez des règles allow exactes `aliyun_api(product:action)` pour l'automatisation de confiance. ## `iac-code a2a-client call` diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/http-transport.md index 9f0270a2..342e8d46 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/http-transport.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -264,6 +264,6 @@ Pour la liste complète des options, consultez la [référence des commandes](./ - Liez à `127.0.0.1` pour une utilisation locale uniquement. - Utilisez `token` dans la configuration A2A ou `IACCODE_A2A_HTTP_TOKEN` avant de lier le serveur à une interface réseau partagée. -- Le mode A2A rejette automatiquement les demandes d'autorisation d'outil ; protégez les endpoints non authentifiés comme des services d'automatisation locaux. +- Le mode A2A rejette automatiquement les demandes d'autorisation d'outil, sauf si `auto-approve-permissions` ou une règle de permission explicite les autorise. Les décisions de permissions sont auditées localement ; toute décision allow nécessitant un enregistrement d'audit échoue en mode fail-closed si cet enregistrement ne peut pas être persisté. Les API d'écriture Alibaba Cloud protégées nécessitent une autorisation exacte par API hors des modes de bypass global. - L'état runtime actif est en mémoire. La persistance duplique les métadonnées de tâche et de contexte, mais le redémarrage du processus ne reprend pas le travail asyncio en cours. - Un contexte ne peut exécuter qu'une seule tâche à la fois ; des contextes séparés peuvent s'exécuter simultanément. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md index 10034616..f228e731 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -71,4 +71,4 @@ iac-code prend en charge le mode serveur A2A via HTTP JSON-RPC/REST et plusieurs - Pas de DAG de planificateur autonome ni d'orchestration multi-agent complexe. - La livraison push est au moins une fois pour les files adossées à Redis ; les récepteurs de callback doivent gérer les doublons et appliquer leur propre politique d'autorisation côté endpoint. -Les demandes d'autorisation d'outil sont rejetées automatiquement en mode serveur A2A. N'exécutez le mode A2A non authentifié que dans des environnements locaux de confiance, ou protégez-le avec une authentification par jeton Bearer, Basic auth ou clé API. +Les demandes d'autorisation d'outil sont rejetées automatiquement en mode serveur A2A, sauf si `auto-approve-permissions` ou une règle de permission explicite les autorise. Les décisions de permissions sont auditées localement ; toute décision allow nécessitant un enregistrement d'audit échoue en mode fail-closed si cet enregistrement ne peut pas être persisté. Les API d'écriture Alibaba Cloud protégées nécessitent toujours une autorisation exacte par API hors des modes de bypass global. N'exécutez le mode A2A non authentifié que dans des environnements locaux de confiance, ou protégez-le avec une authentification par jeton Bearer, Basic auth ou clé API. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 2be95e4d..5afe9cd4 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -363,9 +363,13 @@ Les détails d'outils et d'utilisation sont livrés via `metadata.iac_code` : | `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` ou `failed` | | `iac_code.tool.toolUseId` | ID d'utilisation d'outil stable pour corréler les événements d'outil | | `iac_code.tool.name` | Nom de l'outil lorsqu'il est disponible | -| `iac_code.tool.input` | Entrée d'outil terminée, tronquée à 4000 caractères par champ | +| `iac_code.tool.inputSummary` | Résumé sous forme uniquement de l'entrée d'outil terminée ; les chaînes et les noms de champs hors liste blanche sont représentés sans payload métier brut | | `iac_code.tool.result` | Résultat d'outil, tronqué à 4000 caractères par champ | -| `iac_code.permission.autoApproved` | `false` lorsqu'une demande d'autorisation d'outil a été rejetée par le mode serveur A2A | +| `iac_code.permission.autoApproved` | Indique si A2A a approuvé la demande de permission automatiquement ou via un resolver | +| `iac_code.permission.toolName` | Outil qui a demandé la permission | +| `iac_code.permission.safeSummary` | Résumé de permission expurgé et lisible par un humain | +| `iac_code.permission.inputSummary` | Résumé sûr de l'opération pour les demandes de permission `aliyun_api` | +| `iac_code.permission.toolInput` | Entrée d'outil sous forme uniquement pour les demandes de permission hors `aliyun_api` ; les chaînes utilisent type, longueur et empreinte, et les noms de champs hors liste blanche peuvent être représentés par une empreinte | | `iac_code.thinking.type` | `raw_thinking` lorsque `raw-thinking` est activé dans `thinking-exposure` | | `iac_code.thinking.text` | Chunk brut de raisonnement du provider, tronqué à 4000 caractères, émis seulement pour les configurations de confiance qui activent `raw-thinking` | | `iac_code.usage.inputTokens` | Nombre de jetons d'entrée pour le tour | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index 8a58af24..c23c07e0 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -304,7 +304,7 @@ Chaque notification `session/update` contient un objet de mise à jour avec un t ``` ToolCallStart (status=in_progress) │ - ├── ToolCallProgress (status=in_progress, raw_input=tool input) + ├── ToolCallProgress (status=in_progress, résumé d'entrée / entrée de prompt sûre) │ ├── ToolCallProgress (status=completed, raw_output=result) ← success │ @@ -333,8 +333,10 @@ Avant d'exécuter des outils à haut risque, iac-code envoie un callback `reques | Catégorie | Outils | Auto-approuvé | |----------|-------|-------------| | Lecture seule | `read_file`, `list_files`, `glob`, `grep`, `web_fetch` | Oui | +| API cloud en lecture seule | Actions `aliyun_api` classées en lecture seule | Oui | | Écriture | `write_file`, `edit_file` | Non -- nécessite une approbation | | Exécution | `bash`, `agent` | Non -- nécessite une approbation | +| API cloud d'écriture | Appels `aliyun_api` qui ne sont pas en lecture seule | Non -- nécessite une approbation par API | ### Événement request_permission @@ -351,12 +353,14 @@ Le serveur envoie un callback `request_permission` avec : | Identifiant d'option | Signification | |-----------|---------| | `allow_once` | Autoriser cette invocation spécifique | -| `allow_always` | Autoriser tous les appels futurs de cet outil dans cette session lorsque l'outil prend en charge l'autorisation globale ; non proposé par défaut pour `bash` | +| `allow_always` | Autoriser tous les appels futurs de cet outil dans cette session lorsque l'outil prend en charge l'autorisation globale ; non proposé par défaut pour `bash` ni `aliyun_api` | | `allow_rule:` | Autoriser les futurs appels correspondant aux règles suggérées dans cette session | | `deny_rule:` | Refuser les futurs appels correspondant aux règles suggérées dans cette session | | `reject_once` | Refuser cette invocation spécifique | | `reject_always` | Refuser tous les appels futurs de cet outil dans cette session | +Pour `aliyun_api`, les actions en lecture seule sont autorisées automatiquement. Les actions RPC et ROA qui ne sont pas en lecture seule peuvent proposer une règle exacte comme `aliyun_api(ros:CreateStack)` ou `aliyun_api(cs:CreateCluster)`. Les règles allow avec jokers n'approuvent toujours pas les appels qui ne sont pas en lecture seule. + ### Format de réponse ```json diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 0e527417..876f10ef 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -47,6 +47,14 @@ Lors de l'exécution en mode non interactif, utilisez `--permission-mode` pour c iac-code --prompt "Deploy the stack" --permission-mode bypass_permissions ``` +En `bypass_permissions`, les actions d'outils sont approuvées automatiquement sauf les vérifications de sécurité, mais toute décision allow nécessitant un enregistrement d'audit continue d'échouer en mode fail-closed si la persistance de l'audit échoue. Les API d'écriture Alibaba Cloud restent protégées séparément hors `bypass_permissions` ; pour une automatisation de confiance plus restreinte, n'utilisez pas `bypass_permissions` et autorisez explicitement chaque API d'écriture requise : + +```bash +iac-code --prompt "Deploy the stack" \ + --allowed-tools 'aliyun_api(ros:CreateStack)' \ + --permission-mode dont_ask +``` + Pour restreindre ce que l'agent peut faire, combinez `--allowed-tools` et `--disallowed-tools` : ```bash diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md index bbea499b..4bd91ef5 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -30,9 +30,11 @@ Le paramètre `--permission-mode` contrôle comment l'agent gère les vérificat |---|---| | `default` | L'agent demande une confirmation lorsqu'une action d'outil nécessite une approbation. | | `accept_edits` | Approuver automatiquement les commandes du système de fichiers considérées comme des modifications (ex. `mkdir`, `cp`). Les autres actions demandent toujours confirmation. | -| `bypass_permissions` | Approuver automatiquement toutes les actions d'outils sauf les vérifications de sécurité. Destiné à l'automatisation de confiance. | +| `bypass_permissions` | Approuver automatiquement les actions d'outils sauf les vérifications de sécurité. Toute décision allow nécessitant un enregistrement d'audit échoue en mode fail-closed si la persistance de l'audit échoue. Destiné à l'automatisation de confiance. | | `dont_ask` | Refuser silencieusement toute action qui nécessiterait normalement une confirmation. Utile pour les exécutions strictement en lecture seule. | +Les appels d'API d'écriture Alibaba Cloud effectués via `aliyun_api` sont protégés séparément : une règle allow nue `aliyun_api` ne les approuve pas globalement, et hors `bypass_permissions`, les règles allow d'écriture doivent correspondre exactement à la paire canonique `product:action`. `bypass_permissions` les approuve automatiquement, mais comme les autres décisions allow auditées, l'action est refusée si l'enregistrement d'audit des permissions ne peut pas être persisté. Utilisez des règles exactes comme `aliyun_api(ros:CreateStack)` lorsque l'automatisation de confiance ne doit autoriser qu'une API d'écriture précise. + ## Commandes de démarrage courantes Démarrer le REPL interactif avec le modèle enregistré : @@ -71,6 +73,12 @@ Autoriser uniquement les commandes git et bash en lecture seule : iac-code --allowed-tools 'bash(git *)' ``` +Autoriser une API d'écriture Alibaba Cloud précise : + +```bash +iac-code --allowed-tools 'aliyun_api(ros:CreateStack)' +``` + Exécuter en automatisation sans prompts interactifs : ```bash diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index 8e6394ff..7ca5b5ba 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,6 +52,8 @@ Consultez [Identifiants Alibaba Cloud](./alibaba-cloud-credentials.md) pour plus | Variable | Description | |---|---| | `IAC_CODE_CONFIG_DIR` | Remplace le répertoire de configuration à l'exécution (par défaut `~/.iac-code/`) ; prend en charge l'expansion de `~` et `$VAR`. Tous les artefacts persistés (identifiants, paramètres, historique, projects, image-cache, skills, telemetry, etc.) suivent ce répertoire | +| `IAC_CODE_LOG_DIR` | Remplace le répertoire local des journaux de démarrage/débogage (par défaut `/logs/`) ; prend en charge l'expansion de `~` et `$VAR`. Les enregistrements d'audit des permissions restent dans `/logs/permission-audit.jsonl` | +| `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Remplace `permissions.audit.include_tool_input` ; définissez-le sur `1` / `true` / `yes` / `on` pour inclure une entrée d'outil sous forme uniquement dans les enregistrements d'audit des permissions, avec type/longueur/empreinte au lieu des chaînes de payload métier brutes et avec empreinte pour les noms de champs hors liste blanche | | `IAC_CODE_ENV` | Label d'environnement de déploiement (par défaut : `production`) | | `IAC_CODE_TENANT_ID` | Identifiant de locataire pour la télémétrie ; préfixé automatiquement avec `iac_tenant_` si ce n'est pas déjà le cas | | `IAC_CODE_GIT_BASH_PATH` | Chemin vers `bash.exe` de Git Bash sous Windows lorsqu'il n'est pas dans le PATH | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index f04642d9..44d532b8 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ Le répertoire d'exécution par défaut est : ~/.iac-code/ ``` -Vous pouvez le déplacer en définissant la variable d'environnement `IAC_CODE_CONFIG_DIR` (prend en charge l'expansion de `~` et `$VAR`). Une fois définie, tous les artefacts persistés — identifiants, paramètres, historique, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — suivent le nouvel emplacement. +Vous pouvez le déplacer en définissant la variable d'environnement `IAC_CODE_CONFIG_DIR` (prend en charge l'expansion de `~` et `$VAR`). Une fois définie, tous les artefacts persistés — identifiants, paramètres, historique, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — suivent le nouvel emplacement. Les journaux de démarrage/débogage vont par défaut dans `/logs/` et peuvent être déplacés séparément avec `IAC_CODE_LOG_DIR` ; les enregistrements d'audit des permissions restent dans `/logs/`. Fichiers courants : @@ -106,6 +106,10 @@ permissions: - "bash(curl:*)" additional_directories: - "/tmp/workspace" + audit: + include_tool_input: false + max_file_bytes: 10485760 + max_files: 5 ``` | Champ | Description | @@ -115,6 +119,7 @@ permissions: | `deny` | Liste des modèles de permissions d'outils à refuser automatiquement. | | `ask` | Liste des modèles de permissions d'outils nécessitant toujours une confirmation. | | `additional_directories` | Répertoires supplémentaires au-delà de cwd dans lesquels l'agent peut écrire. | +| `audit` | Paramètres locaux du journal d'audit des permissions. | ### Syntaxe des modèles @@ -126,5 +131,40 @@ Les modèles de permissions d'outils suivent le format `tool_name(rule)` : | `bash(git *)` | Correspondre aux commandes bash commençant par `git`. | | `bash(curl:*)` | Correspondre aux commandes bash commençant par `curl`. | | `write_file` | Correspondre à tous les appels d'outil write_file. | +| `aliyun_api(ros:CreateStack)` | Correspondre à une paire produit/action d'API Alibaba Cloud. | Les règles sont évaluées dans l'ordre : **deny → ask → allow → comportement par défaut**. Les arguments CLI (`--allowed-tools`, `--disallowed-tools`) ont la priorité la plus élevée. + +### Permissions d'API Alibaba Cloud + +`aliyun_api` distingue les appels d'API en lecture seule des appels qui peuvent modifier des ressources cloud. Les actions d'API en lecture seule sont autorisées automatiquement. Les appels d'API qui ne sont pas en lecture seule nécessitent une confirmation ou une règle allow exacte pour ce produit/action, par exemple : + +```yaml +permissions: + allow: + - "aliyun_api(ros:CreateStack)" +``` + +Une règle allow nue `aliyun_api` n'approuve pas globalement les API d'écriture Alibaba Cloud. Hors `bypass_permissions`, les règles allow d'écriture doivent correspondre exactement à la paire canonique `product:action`. En mode `bypass_permissions`, les API d'écriture Alibaba Cloud protégées sont approuvées automatiquement, mais toute décision allow nécessitant un enregistrement d'audit échoue toujours en mode fail-closed si la persistance de l'audit échoue. Les jokers peuvent toujours servir aux règles deny ou ask, ainsi qu'aux correspondances de règles en lecture seule. + +Les requêtes de style ROA sont traitées comme lecture seule uniquement lorsque la méthode est `GET` et que la requête n'a pas de body. Les requêtes ROA qui ne sont pas en lecture seule suivent la même exigence de règle allow canonique exacte `product:action` que les API d'écriture de style RPC : une règle exacte comme `aliyun_api(cs:CreateCluster)` peut approuver l'écriture, tandis que les règles allow avec jokers n'approuvent toujours pas les appels qui ne sont pas en lecture seule. + +### Journal d'audit des permissions + +Les décisions de permissions qui traversent des prompts utilisateur, des frontières de cache d'outils, une approbation d'automatisation ou une approbation de resolver sont ajoutées à : + +```text +/logs/permission-audit.jsonl +``` + +Par défaut, ce chemin est `~/.iac-code/logs/permission-audit.jsonl`. Le journal d'audit des permissions suit `IAC_CODE_CONFIG_DIR` ; `IAC_CODE_LOG_DIR` ne déplace que les journaux de démarrage/débogage. Le writer d'audit ajoute des enregistrements JSONL avec verrouillage de fichier, effectue la rotation du fichier et restreint les permissions locales lorsque le système d'exploitation le permet. Les autorisations automatiques routinières en lecture seule peuvent être omises, mais les refus, prompts, décisions mises en cache, approbations d'automatisation, approbations de resolver et autres frontières de permissions auditées sont enregistrés. + +Les paramètres d'audit se configurent sous `permissions.audit` : + +| Champ | Défaut | Description | +|---|---:|---| +| `include_tool_input` | `false` | Inclure dans les enregistrements d'audit JSONL une entrée d'outil sous forme uniquement. Les chaînes sont stockées sous forme de type, longueur et empreinte ; les clés ressemblant à des secrets sont expurgées ; les noms de champs hors liste blanche peuvent être représentés par une empreinte ; les chaînes de payload métier brutes ne sont pas écrites. Les entrées d'API Alibaba Cloud conservent aussi un résumé d'opération sûr. | +| `max_file_bytes` | `10485760` | Faire tourner `permission-audit.jsonl` lorsqu'il dépasse cette taille. | +| `max_files` | `5` | Nombre de fichiers d'audit tournés à conserver. Les valeurs au-dessus du maximum intégré sont plafonnées. | + +Si une décision allow nécessitant un enregistrement d'audit ne peut pas être persistée dans le journal d'audit, IaC Code échoue en mode fail-closed et refuse l'action au lieu de l'exécuter sans trace d'audit. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md index e5b9736b..15ef0d74 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -258,7 +258,7 @@ thinking-exposure: |--------|---------|-------------| | `auto-approve-permissions` | `false` | A2A ターン中に発生したツール権限リクエストを自動承認 | -`auto-approve-permissions: true` がない場合、A2A モードは権限プロンプトを拒否し、権限メタデータを出力します。信頼できる自動化環境でのみ使用してください。 +`auto-approve-permissions: true` がない場合、A2A モードは権限プロンプトを拒否し、権限メタデータを出力します。有効にすると、権限決定はローカルの権限監査ログに書き込まれます。監査レコードを必要とする allow 決定は、そのレコードを永続化できない場合に fail-closed になります。保護された Alibaba Cloud 書き込み API は通常の allow ルールでは一括承認されません。信頼できる自動化では、正確な `aliyun_api(product:action)` allow ルールを構成してください。 ## `iac-code a2a-client call` diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/http-transport.md index 0a0e889b..be520232 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/http-transport.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -264,6 +264,6 @@ iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id - ローカル専用の利用では `127.0.0.1` にバインドしてください。 - 共有ネットワークインターフェイスにバインドする前に、A2A 設定の `token` または `IACCODE_A2A_HTTP_TOKEN` を使用してください。 -- A2A モードはツール権限リクエストを自動的に拒否します。ローカル自動化サービスのような認証なしエンドポイントは保護してください。 +- A2A モードは、`auto-approve-permissions` または明示的な権限ルールで許可されない限り、ツール権限リクエストを自動的に拒否します。権限決定はローカルで監査され、監査レコードを必要とする allow 決定は、そのレコードを永続化できない場合に fail-closed になります。保護された Alibaba Cloud 書き込み API には、グローバル bypass モード以外では API ごとの正確な承認が必要です。 - アクティブなランタイム状態はメモリ内にあります。永続化はタスクとコンテキストのメタデータをミラーしますが、プロセスを再起動しても実行中の asyncio 作業は再開されません。 - 1 つのコンテキストでは同時に 1 つのタスクだけを実行できます。別々のコンテキストは並行して実行できます。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md index 7d84c0aa..964f7e03 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -71,4 +71,4 @@ iac-code は HTTP JSON-RPC/REST と複数の任意トランスポート上の A2 - 自律プランナー DAG や複雑なマルチエージェントオーケストレーションはありません。 - Redis ベースのキューではプッシュ配信は at-least-once です。コールバック受信側は重複を処理し、エンドポイント側の認可ポリシーを自分で適用する必要があります。 -A2A サーバーモードでは、ツール権限リクエストは自動的に拒否されます。認証なしの A2A モードは信頼できるローカル環境でのみ実行するか、Bearer token、Basic auth、または API key 認証で保護してください。 +A2A サーバーモードでは、`auto-approve-permissions` または明示的な権限ルールで許可されない限り、ツール権限リクエストは自動的に拒否されます。権限決定はローカルで監査され、監査レコードを必要とする allow 決定は、そのレコードを永続化できない場合に fail-closed になります。保護された Alibaba Cloud 書き込み API は、グローバル bypass モード以外では引き続き API ごとの正確な承認が必要です。認証なしの A2A モードは信頼できるローカル環境でのみ実行するか、Bearer token、Basic auth、または API key 認証で保護してください。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 2ca85f7f..23afeb94 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -363,9 +363,13 @@ iac-code は A2A コンテキストを内部エージェントランタイムに | `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed`, or `failed` | | `iac_code.tool.toolUseId` | ツールイベントを関連付ける安定した tool-use ID | | `iac_code.tool.name` | 利用可能な場合のツール名 | -| `iac_code.tool.input` | 完了したツール入力。フィールドごとに 4000 文字へ切り詰め | +| `iac_code.tool.inputSummary` | 完了したツール入力の形状のみの要約。文字列値とホワイトリスト外のフィールド名は、生の業務 payload を含めずに表されます | | `iac_code.tool.result` | ツール結果。フィールドごとに 4000 文字へ切り詰め | -| `iac_code.permission.autoApproved` | A2A サーバーモードによってツール権限リクエストが拒否された場合は `false` | +| `iac_code.permission.autoApproved` | A2A が権限リクエストを自動承認または resolver 経由で承認したかどうか | +| `iac_code.permission.toolName` | 権限を要求したツール | +| `iac_code.permission.safeSummary` | マスク済みの人間向け権限サマリー | +| `iac_code.permission.inputSummary` | `aliyun_api` 権限リクエスト向けの安全な操作サマリー | +| `iac_code.permission.toolInput` | `aliyun_api` 以外の権限リクエスト向けの形状のみのツール入力。文字列値は型、長さ、フィンガープリントで表され、ホワイトリスト外のフィールド名もフィンガープリントで表される場合があります | | `iac_code.thinking.type` | `thinking-exposure` で `raw-thinking` が有効な場合は `raw_thinking` | | `iac_code.thinking.text` | 生の provider 推論チャンク。4000 文字に切り詰められ、信頼済み設定で `raw-thinking` が有効な場合のみ出力 | | `iac_code.usage.inputTokens` | ターンの入力トークン数 | diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index 90b2e0a3..f30902bc 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -304,7 +304,7 @@ initialize → new_session → prompt (loop) → close_session ``` ToolCallStart (status=in_progress) │ - ├── ToolCallProgress (status=in_progress, raw_input=tool input) + ├── ToolCallProgress (status=in_progress, 入力要約 / 安全なプロンプト入力) │ ├── ToolCallProgress (status=completed, raw_output=result) ← success │ @@ -333,8 +333,10 @@ ToolCallStart (status=in_progress) | カテゴリ | ツール | 自動許可 | |----------|-------|-------------| | 読み取り専用 | `read_file`, `list_files`, `glob`, `grep`, `web_fetch` | はい | +| 読み取り専用クラウド API | 読み取り専用に分類された `aliyun_api` アクション | はい | | 書き込み | `write_file`, `edit_file` | いいえ — 承認が必要 | | 実行 | `bash`, `agent` | いいえ — 承認が必要 | +| クラウド書き込み API | 読み取り専用ではない `aliyun_api` 呼び出し | いいえ — API ごとの承認が必要 | ### request_permission イベント @@ -351,12 +353,14 @@ ToolCallStart (status=in_progress) | オプション ID | 意味 | |-----------|---------| | `allow_once` | この特定の呼び出しを許可 | -| `allow_always` | ツールが blanket allow をサポートする場合のみ、このセッション内でこのツールの今後のすべての呼び出しを許可(`bash` ではデフォルトで表示されません) | +| `allow_always` | ツールが blanket allow をサポートする場合のみ、このセッション内でこのツールの今後のすべての呼び出しを許可(`bash` と `aliyun_api` ではデフォルトで表示されません) | | `allow_rule:` | 提案されたルールに一致する今後の呼び出しをこのセッション内で許可 | | `deny_rule:` | 提案されたルールに一致する今後の呼び出しをこのセッション内で拒否 | | `reject_once` | この特定の呼び出しを拒否 | | `reject_always` | このセッション内でこのツールの今後のすべての呼び出しを拒否 | +`aliyun_api` では、読み取り専用アクションは自動的に許可されます。読み取り専用ではない RPC および ROA アクションでは、`aliyun_api(ros:CreateStack)` や `aliyun_api(cs:CreateCluster)` のような正確なルールを提示できます。ワイルドカードの allow ルールは、読み取り専用ではない呼び出しを引き続き承認しません。 + ### レスポンス形式 ```json diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 9db63ac0..6bbbf0fb 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -47,6 +47,14 @@ iac-code --prompt "Create a VPC" --max-turns 20 iac-code --prompt "Deploy the stack" --permission-mode bypass_permissions ``` +`bypass_permissions` では、セーフティチェックを除くツールアクションが自動承認されますが、監査レコードを必要とする allow 決定は、監査の永続化に失敗すると引き続き fail-closed になります。Alibaba Cloud 書き込み API は `bypass_permissions` 以外では引き続き別枠で保護されます。より範囲を絞った信頼できる自動化では、`bypass_permissions` を使わず、必要な書き込み API をそれぞれ明示的に許可してください: + +```bash +iac-code --prompt "Deploy the stack" \ + --allowed-tools 'aliyun_api(ros:CreateStack)' \ + --permission-mode dont_ask +``` + エージェントが実行できる内容を制限するには、`--allowed-tools` と `--disallowed-tools` を組み合わせます: ```bash diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md index a41b3773..1c6a0708 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -30,9 +30,11 @@ description: IaC Code の起動オプションとワンショット実行パラ |---|---| | `default` | ツールアクションが承認を必要とする場合、エージェントが確認を求めます。 | | `accept_edits` | 編集とみなされるファイルシステムコマンド(例:`mkdir`、`cp`)を自動承認します。その他のアクションは引き続き確認を求めます。 | -| `bypass_permissions` | セーフティチェックを除くすべてのツールアクションを自動承認します。信頼できる自動化向けです。 | +| `bypass_permissions` | セーフティチェックを除くツールアクションを自動承認します。監査レコードを必要とする allow 決定は、監査の永続化に失敗すると fail-closed になります。信頼できる自動化向けです。 | | `dont_ask` | 通常確認が必要なアクションを黙って拒否します。厳密な読み取り専用実行に便利です。 | +`aliyun_api` 経由の Alibaba Cloud 書き込み API 呼び出しは別枠で保護されます。裸の `aliyun_api` allow ルールはそれらを一括承認せず、`bypass_permissions` 以外では、書き込み allow ルールは正規化された `product:action` ペアに正確に一致する必要があります。`bypass_permissions` はそれらを自動承認しますが、他の監査対象 allow 決定と同じく、権限監査レコードを永続化できない場合はアクションが拒否されます。信頼できる自動化で特定の書き込み API だけを許可する場合は、`aliyun_api(ros:CreateStack)` のような正確なルールを使用してください。 + ## よく使う起動コマンド 保存済みモデルで対話型 REPL を起動する: @@ -71,6 +73,12 @@ git と読み取り専用の bash コマンドのみ許可する: iac-code --allowed-tools 'bash(git *)' ``` +特定の Alibaba Cloud 書き込み API を 1 つ許可する: + +```bash +iac-code --allowed-tools 'aliyun_api(ros:CreateStack)' +``` + 対話プロンプトなしで自動化実行する: ```bash diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index 9931c373..28a08550 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,6 +52,8 @@ CLI 引数 > 環境変数 > 設定ファイル | 変数 | 説明 | |---|---| | `IAC_CODE_CONFIG_DIR` | ランタイム設定ディレクトリを上書き(デフォルト `~/.iac-code/`)。`~` と `$VAR` の展開をサポート。永続化されるすべての成果物(認証情報、設定、履歴、projects、image-cache、skills、telemetry など)はこのディレクトリに従います | +| `IAC_CODE_LOG_DIR` | ローカルの起動/デバッグログディレクトリを上書き(デフォルト `/logs/`)。`~` と `$VAR` の展開をサポート。権限監査レコードは引き続き `/logs/permission-audit.jsonl` に保存されます | +| `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | `permissions.audit.include_tool_input` を上書きします。`1` / `true` / `yes` / `on` に設定すると、権限監査レコードに形状のみのツール入力を含め、業務 payload の生文字列の代わりに型/長さ/フィンガープリントを記録し、ホワイトリスト外のフィールド名もフィンガープリント化します | | `IAC_CODE_ENV` | デプロイ環境ラベル(デフォルト:`production`) | | `IAC_CODE_TENANT_ID` | テレメトリ用テナント識別子。`iac_tenant_` プレフィックスが付いていない場合は自動的に付加されます | | `IAC_CODE_GIT_BASH_PATH` | Windows で Git Bash が PATH にない場合の `bash.exe` パス | diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 10cf1f66..64cdcbf0 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ CLI 引数 > 環境変数 > 設定ファイル ~/.iac-code/ ``` -`IAC_CODE_CONFIG_DIR` 環境変数を設定すると、ディレクトリを変更できます(`~` と `$VAR` の展開をサポート)。設定すると、永続化されるすべての成果物 — 認証情報、設定、履歴、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/` — が新しい場所に追従します。 +`IAC_CODE_CONFIG_DIR` 環境変数を設定すると、ディレクトリを変更できます(`~` と `$VAR` の展開をサポート)。設定すると、永続化されるすべての成果物 — 認証情報、設定、履歴、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/` — が新しい場所に追従します。起動/デバッグログはデフォルトで `/logs/` に置かれ、`IAC_CODE_LOG_DIR` で別に移動できます。権限監査レコードは `/logs/` に残ります。 主要ファイル: @@ -106,6 +106,10 @@ permissions: - "bash(curl:*)" additional_directories: - "/tmp/workspace" + audit: + include_tool_input: false + max_file_bytes: 10485760 + max_files: 5 ``` | フィールド | 説明 | @@ -115,6 +119,7 @@ permissions: | `deny` | 自動拒否するツール権限パターンのリスト。 | | `ask` | 常に確認が必要なツール権限パターンのリスト。 | | `additional_directories` | cwd 以外でエージェントが書き込み可能な追加ディレクトリ。 | +| `audit` | ローカルの権限監査ログ設定。 | ### パターン構文 @@ -126,5 +131,40 @@ permissions: | `bash(git *)` | `git` で始まる bash コマンドにマッチ。 | | `bash(curl:*)` | `curl` で始まる bash コマンドにマッチ。 | | `write_file` | すべての write_file ツール呼び出しにマッチ。 | +| `aliyun_api(ros:CreateStack)` | Alibaba Cloud API の product/action ペア 1 つにマッチ。 | ルールは次の順序で評価されます:**deny → ask → allow → デフォルト動作**。CLI 引数(`--allowed-tools`、`--disallowed-tools`)が最も高い優先度を持ちます。 + +### Alibaba Cloud API 権限 + +`aliyun_api` は、読み取り専用 API 呼び出しとクラウドリソースを変更し得る呼び出しを区別します。読み取り専用 API アクションは自動的に許可されます。読み取り専用ではない API 呼び出しには確認、またはその product/action に対する正確な allow ルールが必要です。例: + +```yaml +permissions: + allow: + - "aliyun_api(ros:CreateStack)" +``` + +裸の `aliyun_api` allow ルールは Alibaba Cloud の書き込み API を一括承認しません。`bypass_permissions` 以外では、書き込み allow ルールは正規化された `product:action` ペアに正確に一致する必要があります。`bypass_permissions` モードでは、保護された Alibaba Cloud 書き込み API は自動承認されますが、監査レコードを必要とする allow 決定は、監査の永続化に失敗すると引き続き fail-closed になります。ワイルドカードは deny ルールや ask ルール、読み取り専用ルールのマッチングには引き続き有用です。 + +ROA 形式のリクエストは、メソッドが `GET` で body がない場合にのみ読み取り専用として扱われます。読み取り専用ではない ROA リクエストは、RPC 形式の書き込み API と同じく、正規化された `product:action` に正確に一致する allow ルールが必要です。`aliyun_api(cs:CreateCluster)` のような正確なルールなら書き込みを承認できますが、ワイルドカードの allow ルールは読み取り専用ではない呼び出しを引き続き承認しません。 + +### 権限監査ログ + +ユーザープロンプト、ツールキャッシュ境界、自動化承認、または resolver 承認をまたぐ権限決定は、次のファイルに追記されます: + +```text +/logs/permission-audit.jsonl +``` + +デフォルトでは `~/.iac-code/logs/permission-audit.jsonl` です。権限監査ログは `IAC_CODE_CONFIG_DIR` に従います。`IAC_CODE_LOG_DIR` で移動されるのは起動/デバッグログだけです。監査 writer はファイルロック付きで JSONL レコードを追記し、ファイルをローテーションし、OS が対応している場合はローカルファイル権限を制限します。通常の読み取り専用自動許可は省略されることがありますが、拒否、プロンプト、キャッシュ済み決定、自動化承認、resolver 承認、その他の監査対象の権限境界は記録されます。 + +監査設定は `permissions.audit` の下で構成します: + +| フィールド | 既定値 | 説明 | +|---|---:|---| +| `include_tool_input` | `false` | JSONL 監査レコードに、形状のみのツール入力を含めます。文字列値は型、長さ、フィンガープリントとして保存されます。secret らしいキーはマスクされます。ホワイトリスト外のフィールド名はフィンガープリントで表される場合があります。業務 payload の生文字列は書き込まれません。Alibaba Cloud API の項目では安全な操作サマリーも保持します。 | +| `max_file_bytes` | `10485760` | `permission-audit.jsonl` がこのサイズを超えたらローテーションします。 | +| `max_files` | `5` | 保持するローテーション済み監査ファイル数。組み込み上限を超える値は上限に丸められます。 | + +監査レコードを必要とする allow 決定を監査ログへ永続化できない場合、IaC Code は fail-closed となり、監査証跡なしで実行する代わりにそのアクションを拒否します。 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 3730dcb6..7e6fc366 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -258,7 +258,7 @@ thinking-exposure: |--------|--------|-----------| | `auto-approve-permissions` | `false` | Aprovar automaticamente solicitações de permissão de ferramentas levantadas durante turnos A2A | -Sem `auto-approve-permissions: true`, o modo A2A rejeita prompts de permissão e emite metadados de permissão. Use-o apenas em ambientes de automação confiáveis. +Sem `auto-approve-permissions: true`, o modo A2A rejeita prompts de permissão e emite metadados de permissão. Quando habilitado, as decisões de permissão são gravadas no log local de auditoria de permissões; toda decisão allow que exige um registro de auditoria falha de forma fechada se esse registro não puder ser persistido. APIs protegidas de escrita Alibaba Cloud não são aprovadas globalmente por regras allow comuns; configure regras allow exatas `aliyun_api(product:action)` para automação confiável. ## `iac-code a2a-client call` diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/http-transport.md index a3b742ab..69994864 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/http-transport.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -264,6 +264,6 @@ Para a lista completa de opções, consulte a [Referência de comandos](./comman - Faça bind em `127.0.0.1` para uso apenas local. - Use `token` na configuração A2A ou `IACCODE_A2A_HTTP_TOKEN` antes de fazer bind a uma interface de rede compartilhada. -- O modo A2A rejeita solicitações de permissão de ferramentas automaticamente; proteja endpoints não autenticados como serviços de automação local. +- O modo A2A rejeita solicitações de permissão de ferramentas automaticamente, a menos que `auto-approve-permissions` ou uma regra de permissão explícita as permita. Decisões de permissão são auditadas localmente; toda decisão allow que exige um registro de auditoria falha de forma fechada se esse registro não puder ser persistido. APIs protegidas de escrita Alibaba Cloud exigem autorização exata por API fora de modos de bypass global. - O estado ativo do runtime fica em memória. A persistência espelha metadados de tarefas e contextos, mas reiniciar o processo não retoma trabalho asyncio em andamento. - Um contexto pode executar apenas uma tarefa por vez; contextos separados podem executar simultaneamente. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md index a0674cd0..fca3cb80 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -71,4 +71,4 @@ O iac-code suporta modo servidor A2A sobre HTTP JSON-RPC/REST e vários transpor - Não há DAG de planejador autônomo nem orquestração multiagente complexa. - A entrega push é at-least-once para filas baseadas em Redis; receptores de callback devem lidar com duplicatas e aplicar sua própria política de autorização no lado do endpoint. -Solicitações de permissão de ferramentas são rejeitadas automaticamente no modo servidor A2A. Execute o modo A2A não autenticado apenas em ambientes locais confiáveis ou proteja-o com autenticação por Bearer token, Basic auth ou API key. +Solicitações de permissão de ferramentas são rejeitadas automaticamente no modo servidor A2A, a menos que `auto-approve-permissions` ou uma regra de permissão explícita as permita. Decisões de permissão são auditadas localmente; toda decisão allow que exige um registro de auditoria falha de forma fechada se esse registro não puder ser persistido. APIs protegidas de escrita Alibaba Cloud ainda exigem autorização exata por API fora de modos de bypass global. Execute o modo A2A não autenticado apenas em ambientes locais confiáveis ou proteja-o com autenticação por Bearer token, Basic auth ou API key. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index e304250d..fc937504 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -363,9 +363,13 @@ Detalhes de ferramentas e uso são entregues por `metadata.iac_code`: | `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` ou `failed` | | `iac_code.tool.toolUseId` | ID estável de uso de ferramenta para correlacionar eventos de ferramenta | | `iac_code.tool.name` | Nome da ferramenta quando disponível | -| `iac_code.tool.input` | Entrada completa da ferramenta, truncada para 4000 caracteres por campo | +| `iac_code.tool.inputSummary` | Resumo apenas em forma estrutural da entrada completa da ferramenta; valores de string e nomes de campo fora da lista permitida são representados sem payloads de negócio brutos | | `iac_code.tool.result` | Resultado da ferramenta, truncado para 4000 caracteres por campo | -| `iac_code.permission.autoApproved` | `false` quando uma solicitação de permissão de ferramenta foi rejeitada pelo modo servidor A2A | +| `iac_code.permission.autoApproved` | Se o A2A aprovou a solicitação de permissão automaticamente ou por meio de um resolver | +| `iac_code.permission.toolName` | Ferramenta que solicitou permissão | +| `iac_code.permission.safeSummary` | Resumo de permissão redigido e legível por humanos | +| `iac_code.permission.inputSummary` | Resumo seguro da operação para solicitações de permissão de `aliyun_api` | +| `iac_code.permission.toolInput` | Entrada de ferramenta apenas em forma estrutural para solicitações de permissão que não são de `aliyun_api`; valores de string usam tipo, tamanho e fingerprint, e nomes de campo fora da lista permitida podem ser representados por fingerprint | | `iac_code.thinking.type` | `raw_thinking` quando `raw-thinking` está habilitado em `thinking-exposure` | | `iac_code.thinking.text` | Chunk bruto de raciocínio do provider, truncado para 4000 caracteres, emitido apenas em configurações confiáveis que habilitam `raw-thinking` | | `iac_code.usage.inputTokens` | Contagem de tokens de entrada do turno | diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index 25317c43..61a0dc87 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -304,7 +304,7 @@ Cada notificacao `session/update` carrega um objeto de atualizacao com um tipo e ``` ToolCallStart (status=in_progress) │ - ├── ToolCallProgress (status=in_progress, raw_input=tool input) + ├── ToolCallProgress (status=in_progress, resumo de entrada / entrada segura de prompt) │ ├── ToolCallProgress (status=completed, raw_output=result) ← sucesso │ @@ -333,8 +333,10 @@ Antes de executar ferramentas de alto risco, o iac-code envia um callback `reque | Categoria | Ferramentas | Auto-aprovada | |----------|-------|-------------| | Somente leitura | `read_file`, `list_files`, `glob`, `grep`, `web_fetch` | Sim | +| API de nuvem somente leitura | Ações `aliyun_api` classificadas como somente leitura | Sim | | Escrita | `write_file`, `edit_file` | Nao — requer aprovacao | | Execucao | `bash`, `agent` | Nao — requer aprovacao | +| API de escrita de nuvem | Chamadas `aliyun_api` que não são somente leitura | Não — requer aprovação por API | ### Evento request_permission @@ -351,12 +353,14 @@ O servidor envia um callback `request_permission` com: | ID da opcao | Significado | |-----------|---------| | `allow_once` | Permitir esta invocacao especifica | -| `allow_always` | Permitir todas as futuras chamadas desta ferramenta nesta sessao quando a ferramenta oferece permissao global; nao e oferecido para `bash` por padrao | +| `allow_always` | Permitir todas as futuras chamadas desta ferramenta nesta sessao quando a ferramenta oferece permissao global; nao e oferecido para `bash` nem `aliyun_api` por padrao | | `allow_rule:` | Permitir futuras chamadas que correspondam as regras sugeridas nesta sessao | | `deny_rule:` | Negar futuras chamadas que correspondam as regras sugeridas nesta sessao | | `reject_once` | Negar esta invocacao especifica | | `reject_always` | Negar todas as futuras chamadas desta ferramenta nesta sessao | +Para `aliyun_api`, ações somente leitura são permitidas automaticamente. Ações RPC e ROA que não são somente leitura podem oferecer uma regra exata como `aliyun_api(ros:CreateStack)` ou `aliyun_api(cs:CreateCluster)`. Regras allow com wildcards continuam não aprovando chamadas que não são somente leitura. + ### Formato de resposta ```json diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 2a5aeaee..de6cdc61 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -47,6 +47,14 @@ Ao executar em modo não interativo, use `--permission-mode` para controlar como iac-code --prompt "Deploy the stack" --permission-mode bypass_permissions ``` +Em `bypass_permissions`, ações de ferramentas são aprovadas automaticamente exceto verificações de segurança, mas toda decisão allow que exige um registro de auditoria ainda falha de forma fechada se a persistência de auditoria falhar. APIs de escrita Alibaba Cloud continuam protegidas separadamente fora de `bypass_permissions`; para uma automação confiável mais restrita, não use `bypass_permissions` e permita explicitamente cada API de escrita necessária: + +```bash +iac-code --prompt "Deploy the stack" \ + --allowed-tools 'aliyun_api(ros:CreateStack)' \ + --permission-mode dont_ask +``` + Para restringir o que o agente pode fazer, combine `--allowed-tools` e `--disallowed-tools`: ```bash diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md index 745850fc..4f6bee21 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -30,9 +30,11 @@ O parâmetro `--permission-mode` controla como o agente lida com as verificaçõ |---|---| | `default` | O agente solicita confirmação quando uma ação de ferramenta requer aprovação. | | `accept_edits` | Aprovar automaticamente comandos do sistema de arquivos considerados como edições (ex. `mkdir`, `cp`). Outras ações ainda solicitam confirmação. | -| `bypass_permissions` | Aprovar automaticamente todas as ações de ferramentas exceto verificações de segurança. Destinado para automação confiável. | +| `bypass_permissions` | Aprovar automaticamente ações de ferramentas exceto verificações de segurança. Toda decisão allow que exige um registro de auditoria falha de forma fechada se a persistência de auditoria falhar. Destinado para automação confiável. | | `dont_ask` | Negar silenciosamente qualquer ação que normalmente solicitaria confirmação. Útil para execuções estritamente somente leitura. | +Chamadas de API de escrita Alibaba Cloud feitas por `aliyun_api` são protegidas separadamente: uma regra allow simples `aliyun_api` não as aprova globalmente, e fora de `bypass_permissions` as regras allow de escrita devem corresponder exatamente ao par canônico `product:action`. `bypass_permissions` as aprova automaticamente, mas, como outras decisões allow auditadas, a ação é negada se o registro de auditoria de permissões não puder ser persistido. Use regras exatas como `aliyun_api(ros:CreateStack)` quando a automação confiável deve permitir apenas uma API de escrita específica. + ## Comandos de inicialização comuns Iniciar o REPL interativo com o modelo salvo: @@ -71,6 +73,12 @@ Permitir apenas comandos git e bash somente leitura: iac-code --allowed-tools 'bash(git *)' ``` +Permitir uma API de escrita Alibaba Cloud específica: + +```bash +iac-code --allowed-tools 'aliyun_api(ros:CreateStack)' +``` + Executar em automação sem prompts interativos: ```bash diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index c128f53d..1ccb6640 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,6 +52,8 @@ Consulte [Credenciais da Alibaba Cloud](./alibaba-cloud-credentials.md) para mai | Variavel | Descricao | |---|---| | `IAC_CODE_CONFIG_DIR` | Substitui o diretorio de configuracao em tempo de execucao (padrao `~/.iac-code/`); suporta expansao de `~` e `$VAR`. Todos os artefatos persistidos (credenciais, configuracoes, historico, projects, image-cache, skills, telemetry, etc.) seguem este diretorio | +| `IAC_CODE_LOG_DIR` | Substitui o diretório local de logs de inicialização/depuração (padrão `/logs/`); suporta expansão de `~` e `$VAR`. Registros de auditoria de permissões continuam em `/logs/permission-audit.jsonl` | +| `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Substitui `permissions.audit.include_tool_input`; defina como `1` / `true` / `yes` / `on` para incluir entrada de ferramenta apenas em forma estrutural nos registros de auditoria de permissões, usando tipo/tamanho/fingerprint em vez de strings brutas de payload de negócio e aplicando fingerprint a nomes de campo fora da lista permitida | | `IAC_CODE_ENV` | Rotulo do ambiente de implantacao (padrao: `production`) | | `IAC_CODE_TENANT_ID` | Identificador de tenant para telemetria; prefixado automaticamente com `iac_tenant_` se ainda nao estiver | | `IAC_CODE_GIT_BASH_PATH` | Caminho para `bash.exe` do Git Bash no Windows quando nao esta no PATH | diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 75c0f1c3..1421df3e 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ O diretório de tempo de execução padrão é: ~/.iac-code/ ``` -Você pode realocá-lo definindo a variável de ambiente `IAC_CODE_CONFIG_DIR` (suporta expansão de `~` e `$VAR`). Quando definida, todos os artefatos persistidos — credenciais, configurações, histórico, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — seguem o novo local. +Você pode realocá-lo definindo a variável de ambiente `IAC_CODE_CONFIG_DIR` (suporta expansão de `~` e `$VAR`). Quando definida, todos os artefatos persistidos — credenciais, configurações, histórico, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — seguem o novo local. Logs de inicialização/depuração ficam por padrão em `/logs/` e podem ser movidos separadamente com `IAC_CODE_LOG_DIR`; registros de auditoria de permissões permanecem em `/logs/`. Arquivos comuns: @@ -106,6 +106,10 @@ permissions: - "bash(curl:*)" additional_directories: - "/tmp/workspace" + audit: + include_tool_input: false + max_file_bytes: 10485760 + max_files: 5 ``` | Campo | Descrição | @@ -115,6 +119,7 @@ permissions: | `deny` | Lista de padrões de permissão de ferramentas para negação automática. | | `ask` | Lista de padrões de permissão de ferramentas que sempre requerem confirmação. | | `additional_directories` | Diretórios adicionais além do cwd nos quais o agente pode escrever. | +| `audit` | Configurações locais do log de auditoria de permissões. | ### Sintaxe de padrões @@ -126,5 +131,40 @@ Os padrões de permissão de ferramentas seguem o formato `tool_name(rule)`: | `bash(git *)` | Corresponder a comandos bash que começam com `git`. | | `bash(curl:*)` | Corresponder a comandos bash que começam com `curl`. | | `write_file` | Corresponder a todas as chamadas da ferramenta write_file. | +| `aliyun_api(ros:CreateStack)` | Corresponder a um par produto/ação de API Alibaba Cloud. | As regras são avaliadas na ordem: **deny → ask → allow → comportamento padrão**. Os argumentos CLI (`--allowed-tools`, `--disallowed-tools`) têm a maior precedência. + +### Permissões de API Alibaba Cloud + +`aliyun_api` distingue chamadas de API somente leitura de chamadas que podem modificar recursos de nuvem. Ações de API somente leitura são permitidas automaticamente. Chamadas de API que não são somente leitura exigem confirmação ou uma regra allow exata para esse produto/ação, por exemplo: + +```yaml +permissions: + allow: + - "aliyun_api(ros:CreateStack)" +``` + +Uma regra allow simples `aliyun_api` não aprova globalmente APIs de escrita Alibaba Cloud. Fora de `bypass_permissions`, regras allow de escrita devem corresponder exatamente ao par canônico `product:action`. No modo `bypass_permissions`, APIs protegidas de escrita Alibaba Cloud são aprovadas automaticamente, mas toda decisão allow que exige um registro de auditoria ainda falha de forma fechada se a persistência de auditoria falhar. Wildcards ainda podem ser úteis para regras deny ou ask e para correspondência de regras somente leitura. + +Requisições no estilo ROA são tratadas como somente leitura apenas quando o método é `GET` e a requisição não tem body. Requisições ROA que não são somente leitura seguem o mesmo requisito de regra allow canônica exata `product:action` das APIs de escrita no estilo RPC: uma regra exata como `aliyun_api(cs:CreateCluster)` pode aprovar a escrita, enquanto regras allow com wildcards continuam não aprovando chamadas que não são somente leitura. + +### Log de auditoria de permissões + +Decisões de permissão que cruzam prompts do usuário, limites de cache de ferramentas, aprovação de automação ou aprovação de resolver são anexadas a: + +```text +/logs/permission-audit.jsonl +``` + +Por padrão, este caminho é `~/.iac-code/logs/permission-audit.jsonl`. O log de auditoria de permissões segue `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` move apenas logs de inicialização/depuração. O gravador de auditoria anexa registros JSONL com bloqueio de arquivo, rotaciona o arquivo e restringe permissões locais do arquivo quando o sistema operacional oferece suporte. Aprovações automáticas rotineiras de somente leitura podem ser omitidas, mas negações, prompts, decisões em cache, aprovações de automação, aprovações de resolver e outros limites de permissão auditados são registrados. + +As configurações de auditoria são definidas em `permissions.audit`: + +| Campo | Padrão | Descrição | +|---|---:|---| +| `include_tool_input` | `false` | Incluir entrada de ferramenta apenas em forma estrutural nos registros JSONL de auditoria. Valores de string são gravados como tipo, tamanho e fingerprint; chaves com aparência de segredo são redigidas; nomes de campo fora da lista permitida podem ser representados por fingerprint; strings brutas de payload de negócio não são gravadas. Entradas de API Alibaba Cloud também mantêm um resumo seguro da operação. | +| `max_file_bytes` | `10485760` | Rotacionar `permission-audit.jsonl` quando ultrapassar este tamanho. | +| `max_files` | `5` | Número de arquivos de auditoria rotacionados a manter. Valores acima do máximo integrado são limitados. | + +Se uma decisão allow que exige um registro de auditoria não puder ser persistida no log de auditoria, o IaC Code falha de forma fechada e nega a ação em vez de executá-la sem trilha de auditoria. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md index e424a001..dcbc746a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -260,7 +260,7 @@ thinking-exposure: |--------|---------|-------------| | `auto-approve-permissions` | `false` | 自动批准 A2A 轮次期间发起的工具权限请求 | -如果没有 `auto-approve-permissions: true`,A2A 模式会拒绝权限提示并发出权限元数据。仅在受信任的自动化环境中使用它。 +如果没有 `auto-approve-permissions: true`,A2A 模式会拒绝权限提示并发出权限元数据。启用后,权限决策会写入本地权限审计日志;任何需要审计记录的允许决策在审计记录无法持久化时都会 fail closed。受保护的阿里云写 API 不会被普通允许规则一概批准;可信自动化需要配置精确的 `aliyun_api(product:action)` 允许规则。 ## `iac-code a2a-client call` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/http-transport.md index bbc38310..7db1fcdd 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/http-transport.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -264,6 +264,6 @@ iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id - 对仅本地使用,请绑定到 `127.0.0.1`。 - 绑定到共享网络接口前,请在 A2A 配置中使用 `token` 或设置 `IACCODE_A2A_HTTP_TOKEN`。 -- A2A 模式会自动拒绝工具权限请求;请像保护本地自动化服务一样保护未认证端点。 +- A2A 模式会自动拒绝工具权限请求,除非 `auto-approve-permissions` 或显式权限规则允许。权限决策会在本地审计;任何需要审计记录的允许决策在审计记录无法持久化时都会 fail closed。在非 blanket bypass 模式下,受保护的阿里云写 API 需要按 API 精确授权。 - 活动运行时状态位于内存中。持久化会镜像任务和上下文元数据,但重启进程不会恢复正在运行的 asyncio 工作。 - 一个上下文同一时间只能运行一个任务;不同上下文可以并发运行。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md index 892fd96f..64836fe0 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -71,4 +71,4 @@ iac-code 支持通过 HTTP JSON-RPC/REST 以及若干可选传输运行 A2A serv - 没有自主 planner DAG 或复杂多 agent 编排。 - Redis 后端队列的推送投递是至少一次;callback 接收方必须处理重复投递,并执行自己的端点侧授权策略。 -在 A2A server 模式下,工具权限请求会被自动拒绝。只应在受信任的本地环境中运行未认证的 A2A 模式,或者使用 Bearer token、Basic auth 或 API key authentication 进行保护。 +在 A2A server 模式下,除非 `auto-approve-permissions` 或显式权限规则允许,否则工具权限请求会被自动拒绝。权限决策会在本地审计;任何需要审计记录的允许决策在审计记录无法持久化时都会 fail closed。在非 blanket bypass 模式下,受保护的阿里云写 API 仍然需要按 API 精确授权。只应在受信任的本地环境中运行未认证的 A2A 模式,或者使用 Bearer token、Basic auth 或 API key authentication 进行保护。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index a312dee3..dbb1bb6a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -383,9 +383,13 @@ Assistant text 作为 status message 投递: | `iac_code.tool.status` | `started`、`input_delta`、`input_complete`、`completed` 或 `failed` | | `iac_code.tool.toolUseId` | 用于关联工具事件的稳定 tool-use ID | | `iac_code.tool.name` | 可用时的工具名称 | -| `iac_code.tool.input` | 已完成工具输入,每个字段截断为 4000 个字符 | +| `iac_code.tool.inputSummary` | 已完成工具输入的仅形态摘要;字符串值和非白名单字段名不会以业务原文表示 | | `iac_code.tool.result` | 工具结果,每个字段截断为 4000 个字符 | -| `iac_code.permission.autoApproved` | A2A server 模式拒绝工具权限请求时为 `false` | +| `iac_code.permission.autoApproved` | A2A 是否通过自动批准或 resolver 批准了该权限请求 | +| `iac_code.permission.toolName` | 请求权限的工具 | +| `iac_code.permission.safeSummary` | 脱敏后的人类可读权限摘要 | +| `iac_code.permission.inputSummary` | `aliyun_api` 权限请求的安全操作摘要 | +| `iac_code.permission.toolInput` | 非 `aliyun_api` 权限请求的仅形态工具输入;字符串值使用类型、长度和指纹表示,非白名单字段名可能以指纹表示 | | `iac_code.thinking.type` | 在 `thinking-exposure` 中启用 `raw-thinking` 时为 `raw_thinking` | | `iac_code.thinking.text` | 原始 provider 推理 chunk,截断为 4000 个字符,仅在受信任配置启用 `raw-thinking` 时发出 | | `iac_code.usage.inputTokens` | 该轮次的 input token 数 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index f039c0b2..66cfd976 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -304,7 +304,7 @@ Fork 一个现有会话,创建一个具有相同历史记录的独立分支。 ``` ToolCallStart (status=in_progress) │ - ├── ToolCallProgress (status=in_progress, raw_input=工具输入) + ├── ToolCallProgress (status=in_progress, 输入摘要 / 安全提示输入) │ ├── ToolCallProgress (status=completed, raw_output=结果) ← 成功 │ @@ -333,8 +333,10 @@ ToolCallStart (status=in_progress) | 分类 | 工具 | 自动允许 | |----------|-------|-------------| | 只读 | `read_file`, `list_files`, `glob`, `grep`, `web_fetch` | 是 | +| 只读云 API | 被判定为只读的 `aliyun_api` 动作 | 是 | | 写入 | `write_file`, `edit_file` | 否 — 需要审批 | | 执行 | `bash`, `agent` | 否 — 需要审批 | +| 云写 API | 非只读 `aliyun_api` 调用 | 否 — 需要按 API 批准 | ### request_permission 事件 @@ -351,12 +353,14 @@ ToolCallStart (status=in_progress) | 选项 ID | 含义 | |-----------|---------| | `allow_once` | 允许本次调用 | -| `allow_always` | 当工具支持整工具永久允许时,允许本会话中该工具的所有后续调用;`bash` 默认不提供 | +| `allow_always` | 当工具支持整工具永久允许时,允许本会话中该工具的所有后续调用;`bash` 和 `aliyun_api` 默认不提供 | | `allow_rule:` | 允许本会话中匹配建议规则的后续调用 | | `deny_rule:` | 拒绝本会话中匹配建议规则的后续调用 | | `reject_once` | 拒绝本次调用 | | `reject_always` | 拒绝本会话中该工具的所有后续调用 | +对于 `aliyun_api`,只读动作会自动允许。非只读 RPC 和 ROA 动作都可以提供形如 `aliyun_api(ros:CreateStack)` 或 `aliyun_api(cs:CreateCluster)` 的精确规则。通配允许规则仍不会批准非只读调用。 + ### 响应格式 ```json diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index a7e46df6..af26ed96 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -47,6 +47,14 @@ iac-code --prompt "创建一个 VPC" --max-turns 20 iac-code --prompt "部署资源栈" --permission-mode bypass_permissions ``` +在 `bypass_permissions` 下,工具操作会被自动批准(安全检查除外),但任何需要审计记录的允许决策在审计持久化失败时都会 fail closed。阿里云写 API 在 `bypass_permissions` 之外仍有单独保护;对于更窄范围的可信自动化,请不要使用 `bypass_permissions`,而是显式允许每个需要的写 API: + +```bash +iac-code --prompt "部署资源栈" \ + --allowed-tools 'aliyun_api(ros:CreateStack)' \ + --permission-mode dont_ask +``` + 要限制代理的操作范围,可以组合使用 `--allowed-tools` 和 `--disallowed-tools`: ```bash diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md index 38575fd9..e980cfb1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/cli/command-line-options.md @@ -30,9 +30,11 @@ description: IaC Code 启动选项和一次性执行参数参考。 |---|---| | `default` | 当工具操作需要确认时,代理会弹出提示让用户选择。 | | `accept_edits` | 自动批准被视为编辑操作的文件系统命令(如 `mkdir`、`cp`),其他操作仍需确认。 | -| `bypass_permissions` | 自动批准所有工具操作(安全检查除外)。适用于可信的自动化场景。 | +| `bypass_permissions` | 自动批准工具操作(安全检查除外)。任何需要审计记录的允许决策在审计持久化失败时都会 fail closed。适用于可信的自动化场景。 | | `dont_ask` | 静默拒绝所有需要确认的操作。适用于严格的只读运行。 | +通过 `aliyun_api` 发起的阿里云写 API 调用有单独保护:裸的 `aliyun_api` 允许规则不会一概批准这些调用,并且在 `bypass_permissions` 之外,写入允许规则必须精确匹配规范化后的 `product:action`。`bypass_permissions` 会自动批准它们,但与其他被审计的允许决策一样,如果权限审计记录无法持久化,该操作会被拒绝。需要在可信自动化中只允许某个具体写 API 时,请使用 `aliyun_api(ros:CreateStack)` 这样的精确规则。 + ## 常用启动命令 使用已保存的模型进入交互式 REPL: @@ -71,6 +73,12 @@ iac-code --continue iac-code --allowed-tools 'bash(git *)' ``` +仅允许一个具体的阿里云写 API: + +```bash +iac-code --allowed-tools 'aliyun_api(ros:CreateStack)' +``` + 在自动化中运行,无需交互确认: ```bash diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index cbae1827..329cba1d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,6 +52,8 @@ CLI 参数 > 环境变量 > 配置文件 | 变量 | 说明 | |---|---| | `IAC_CODE_CONFIG_DIR` | 覆盖运行时配置目录(默认 `~/.iac-code/`);支持 `~` 和 `$VAR` 展开。所有持久化产物(凭证、设置、历史、projects、image-cache、skills、telemetry 等)均会跟随该目录 | +| `IAC_CODE_LOG_DIR` | 覆盖本地启动/调试日志目录(默认 `/logs/`);支持 `~` 和 `$VAR` 展开。权限审计记录仍保留在 `/logs/permission-audit.jsonl` | +| `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | 覆盖 `permissions.audit.include_tool_input`;设为 `1` / `true` / `yes` / `on` 时,在权限审计记录中包含仅保留形态的工具输入,使用类型/长度/指纹而不是业务原文,并对非白名单字段名使用指纹 | | `IAC_CODE_ENV` | 部署环境标签(默认:`production`) | | `IAC_CODE_TENANT_ID` | 遥测租户标识;如未以 `iac_tenant_` 开头则自动添加前缀 | | `IAC_CODE_GIT_BASH_PATH` | Windows 下 Git Bash `bash.exe` 的路径(不在 PATH 中时使用) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index a0b79680..6d38af2e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ CLI 参数 > 环境变量 > 配置文件 ~/.iac-code/ ``` -可通过设置 `IAC_CODE_CONFIG_DIR` 环境变量更改该目录(支持 `~` 和 `$VAR` 展开)。设置后,所有持久化产物——凭证、设置、历史、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/`——都会跟随到新位置。 +可通过设置 `IAC_CODE_CONFIG_DIR` 环境变量更改该目录(支持 `~` 和 `$VAR` 展开)。设置后,所有持久化产物——凭证、设置、历史、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/`——都会跟随到新位置。启动/调试日志默认位于 `/logs/`,可用 `IAC_CODE_LOG_DIR` 单独移动;权限审计记录仍位于 `/logs/`。 常见文件: @@ -106,6 +106,10 @@ permissions: - "bash(curl:*)" additional_directories: - "/tmp/workspace" + audit: + include_tool_input: false + max_file_bytes: 10485760 + max_files: 5 ``` | 字段 | 说明 | @@ -115,6 +119,7 @@ permissions: | `deny` | 自动拒绝的工具权限模式列表。 | | `ask` | 始终需要确认的工具权限模式列表。 | | `additional_directories` | 允许代理写入的额外目录(cwd 之外)。 | +| `audit` | 本地权限审计日志设置。 | ### 模式语法 @@ -126,5 +131,40 @@ permissions: | `bash(git *)` | 匹配以 `git` 开头的 bash 命令。 | | `bash(curl:*)` | 匹配以 `curl` 开头的 bash 命令。 | | `write_file` | 匹配所有 write_file 工具调用。 | +| `aliyun_api(ros:CreateStack)` | 匹配一个阿里云 API 产品/动作对。 | 规则按以下顺序评估:**deny → ask → allow → 默认行为**。CLI 参数(`--allowed-tools`、`--disallowed-tools`)具有最高优先级。 + +### 阿里云 API 权限 + +`aliyun_api` 会区分只读 API 调用和可能修改云资源的调用。只读 API 动作会自动允许。非只读 API 调用需要确认,或需要为该产品/动作配置精确允许规则,例如: + +```yaml +permissions: + allow: + - "aliyun_api(ros:CreateStack)" +``` + +裸的 `aliyun_api` 允许规则不会一概批准阿里云写 API。在 `bypass_permissions` 之外,写入允许规则必须精确匹配规范化后的 `product:action`。在 `bypass_permissions` 模式下,受保护的阿里云写 API 会被自动批准,但任何需要审计记录的允许决策在审计持久化失败时都会 fail closed。通配符仍可用于 deny 或 ask 规则,也可用于只读规则匹配。 + +ROA 风格请求只有在方法为 `GET` 且请求没有 body 时才视为只读。非只读 ROA 请求与 RPC 风格写 API 一样,都必须精确匹配规范化后的 `product:action` 允许规则:形如 `aliyun_api(cs:CreateCluster)` 的精确规则可以批准该写操作,但通配允许规则仍不会批准非只读调用。 + +### 权限审计日志 + +跨越用户提示、工具缓存边界、自动化批准或 resolver 批准的权限决策会追加写入: + +```text +/logs/permission-audit.jsonl +``` + +默认情况下,该路径是 `~/.iac-code/logs/permission-audit.jsonl`。权限审计日志跟随 `IAC_CODE_CONFIG_DIR`;`IAC_CODE_LOG_DIR` 只移动启动/调试日志。审计写入器会用文件锁追加 JSONL 记录,执行日志轮转,并在操作系统支持时限制本地文件权限。常规只读自动允许决策可能会省略,但拒绝、提示、缓存决策、自动化批准、resolver 批准以及其他被审计的权限边界都会记录。 + +审计设置位于 `permissions.audit` 下: + +| 字段 | 默认值 | 说明 | +|---|---:|---| +| `include_tool_input` | `false` | 在 JSONL 审计记录中包含仅保留形态的工具输入。字符串会记录为类型、长度和指纹;疑似密钥的字段会被隐藏;非白名单字段名可能以指纹表示;不会写入业务原文。阿里云 API 条目还会保留安全的操作摘要。 | +| `max_file_bytes` | `10485760` | 当 `permission-audit.jsonl` 超过此大小时进行轮转。 | +| `max_files` | `5` | 保留的轮转审计文件数量。超过内置上限的值会被截断到上限。 | + +如果任何需要审计记录的允许决策无法持久化到审计日志,IaC Code 会 fail closed,拒绝该操作,而不是在没有审计轨迹的情况下执行。