Service Approval — Generator: Preventive & Proactive Controls
Generate all preventive and proactive security control artifacts from the Controls Matrix. This covers Organisation×Proactive, Organisation×Preventive, Account×Proactive, Account×Preventive, Resource×Proactive, and Resource×Preventive cells.
Output: .service-approval/<slug>/05-generate/preventive/ and .service-approval/<slug>/05-generate/proactive/
This is one of 3 focused generate sub-skills. Each writes to separate directories.
Prerequisites
test -f .service-approval/<slug>/04-map/mapping-results.json && echo "mapping-results: OK" || echo "ERROR"
test -f .service-approval/<slug>/03-validate/validated.json && echo "validated: OK" || echo "ERROR"
mkdir -p .service-approval/<slug>/05-generate/{preventive,proactive/opa-policies}
Load controls from mapping-results.json and API surface from validated.json:
python3 -c "
import json
mr = json.load(open('.service-approval/<slug>/04-map/mapping-results.json'))
vj = json.load(open('.service-approval/<slug>/03-validate/validated.json'))
prv = [c for c in mr['controls'] if c['category'] == 'PRV']
pro = [c for c in mr['controls'] if c['category'] == 'PRO']
print(f'Preventive controls: {len(prv)}')
print(f'Proactive controls: {len(pro)}')
print(f'API operations: {len(vj[\"api_surface\"][\"operations\"])}')
"
Unless --include-unverified is set, skip controls where verified: false.
Artifact Header Template
Every generated artifact MUST include these comment lines at the top:
# SCOPE: <org|account|resource>
# LAYER: <proactive|preventive>
# POSTURE: <preventative-request|preventative-proactive>
# CONTROLS: <comma-separated control IDs>
# FRAMEWORK: <framework-name> — <FULL sorted union of ALL MAPPED objective IDs>
# MITIGATIONS: <comma-separated mitigation IDs>
# GENERATED: service-approval v3.0.0
# SERVICE: <service-name>
For JSON files, add as a "_metadata" key AND set _metadata.posture to the exact
posture value from mapping-results.json.controls[].posture for the control this
artifact implements. For HCL/TypeScript, use comment blocks.
CHECK-15 enforcement: Every generated artifact's _metadata.posture MUST match its
directory family:
preventative-request→preventive/{scp,rcp,iam-policies,permission-boundary,resource-policy,vpce-policy,kms-key-policy}*.jsonpreventative-proactive→proactive/**oriac/**
An SCP tagged with posture: "reactive-detective" would fail the hook — the upstream
map-generate-controls Rule C1 filter should never have produced it. If the hook
fires, trace back to the control's posture in mapping-results.json and fix the root
cause (either the posture classification or the mechanism selection).
The FRAMEWORK line MUST be copied VERBATIM from
mapping-results.json.framework_header_canonical — scripts/map-assemble.py precomputes
this sorted-union string once so every artifact emits the identical header. Do NOT re-derive
the list from _metadata.controls[] or from per-control framework_objectives[] — that
produces per-artifact subsets and fails CHECK-X1 (validate_cross.py:120), which asserts
every artifact shares one framework header. Read framework_header_canonical, paste it
after # FRAMEWORK: (and into _metadata.framework for JSON files), done.
Rule C2 — SAR-driven mechanism contents (REQUIRED)
All mechanisms in this skill that operate at AWS IAM request-time evaluation are SAR-driven. That includes: SCPs, RCPs, IAM identity-based policies, IAM trust policies, permission boundaries, resource-based policies, VPCE policies, KMS key policies.
For every such artifact:
Every Condition-block key MUST come from
validated.json.capabilities.iam.condition_keys[], the AWS global key set (e.g.,aws:PrincipalARN,aws:SourceAccount,aws:RequestTag/*), or — for KMS key policy files only — the KMS service-owned key set (kms:ViaService,kms:EncryptionContext:*,kms:GrantOperations, etc.).API parameter paths are NEVER valid condition keys. A literal like
"$.networkConfiguration.awsvpcConfiguration.assignPublicIp"in a Condition block is silently ignored by AWS IAM — the intended guardrail does not fire. Instead, find the SAR condition key that gates the parameter (look up the parameter invalidated.json.api_surface.operations[].parameters[]and read itssar_condition_keyfield, populated by Phase B enrichment).Every Action in the statement MUST come from
validated.json.api_surface.operations[].operationORcapabilities.iam.permission_only_actions[](this was already required; restated here for completeness — SAR-driven mechanisms are grounded in both the action list AND the condition-key list).Action-name verification (HARD RULE). Every IAM
Actionstring in every policy artifact (SCP, resource policy, IAM policy, permission boundary, KMS key policy, VPCE policy) MUST appear verbatim (case-sensitive) in one of:sar-facts.jsonaction list orpermission_only_actions[]research-api-surface.json.api_surface.operations[].operationvalidated.json.api_surface.operations[].operation(post-verification authoritative)
Do NOT generate action names by analogy or extrapolation. If a mitigation statement references an action in English prose (e.g., "prevent memory searches"), look up the actual SAR action that implements the concept — do not translate the English verb into an action suffix. If the service does not implement an action the control requires, document it as a compensating control in
compensating-controls-documentation.jsonrather than inventing an action name.Example of the failure mode (real regression observed in production runs):
- WRONG:
<service-prefix>:SearchRecords— invented by the LLM; does not exist in SAR or botocore. IAM AccessAnalyzer rejects with "The action X does not exist." - RIGHT:
<service-prefix>:RetrieveRecords— the actual SAR action.
IAM AccessAnalyzer at Tier 2 validates every action against the live service-prefix registry. Fabricated actions pass JSON-syntax checks but fail deploy-time policy validation.
Validator CHECK-14a enforces rule 1 at hook time. A condition-block key that is not a SAR/global/KMS key fails the hook with a remediation message. The #1 failure mode this prevents is an agent treating an API parameter as if it were a condition key — a class of silent-ignore bug that was endemic before Phase B enrichment.
Zero-service-condition-keys branch
Some services (examples: DataSync, Athena, Step Functions in some configurations)
publish NO service-specific condition keys — only the global tag keys
(aws:RequestTag/${TagKey}, aws:ResourceTag/${TagKey}, aws:TagKeys,
aws:PrincipalTag/${TagKey}) apply. You can tell deterministically by inspecting
validated.json.capabilities.iam.condition_keys[]: if every entry's key starts
with aws:, the service has zero service-specific keys.
In that branch, SCPs and permission boundaries can ONLY gate tag-based controls (creation-time tagging, tag-mandated resource categories) — they CANNOT gate service-specific properties like encryption mode, network mode, result location, workgroup enforcement flags, etc. Do NOT fabricate a service-specific condition key to make an SCP statement work. CHECK-14a rejects invented keys, and even if it didn't, AWS IAM would silently ignore the condition and the guardrail would be a no-op.
Instead, route non-tag gating to mechanisms that can see the property values directly:
- Preventative-proactive → CloudFormation Guard rules (
proactive/cfn-guard-rules.guard) and Checkov custom policies (proactive/custom-policies/). These evaluate template bodies before deploy and have full visibility into every property. - Reactive-detective → AWS Config Rules with Lambda handlers
(
detective/config-rule-lambdas/**/handler.py). These evaluate deployed resources via the Config configuration item. - Reactive-corrective → SSM Automation runbooks (
responsive/ssm-runbook.yaml) or Step Functions workflows (responsive/stepfunctions-workflow.json) invoked by EventBridge on the relevant AWS API events.
The map-generate-controls Rule C1 feasibility filter already routes around
SCP/IAM infeasibility at control-selection time — this rule ensures the
generate-preventive skill doesn't produce SCP artifacts whose statements would
be silently ignored at runtime.
Organisation × Proactive
Tag Policy (preventive/tag-policy.json)
- Enforce: Owner, Environment, Purpose, CostCenter + service-specific tags from controls
- Use
awsknowledgeMCP to confirm valid tag key formats
OPA Org Policy (proactive/opa-policies/org-policy.rego)
- OPA
.regorules for org-wide provisioning rules not expressible as tag policies
SCP Provisioning Deny (preventive/scp-provisioning.json)
- Only for controls where
scp_expressible: truewith provisioning-time intent - Follow SCP authoring rules from Organisation × Preventive below
Organisation × Preventive
SCP Runtime Deny (preventive/scp-policy.json)
Only generate if validated.json capabilities.organization_policies.scp_expressible: true.
If false, write _metadata.scp_gap note and skip.
SCP authoring rules (strict):
Always
"Effect": "Deny"— never Allow-onlyAlways include relevant condition key(s) — never bare action deny without conditions
Use real condition keys from
awsknowledgeMCP — confirm each key is valid for the actionNo custom placeholders in policy documents: SCPs are IAM policies. Custom placeholders like
${trusted:account-id}or${ORGANIZATION_ID}causeMalformedPolicyDocumenterrors. IAM policy variables like${aws:PrincipalAccount}work in real SCPs evaluated by Organizations but fail when the policy is tested as a standalone IAM managed policy (which a downstream policy tester typically does). Use literal placeholder values like123456789012with a_metadata._deployment_notesfield explaining what values to substitute at deployment time.ARN region and account placeholders (HARD RULE): In ARN region and account segments, NEVER use shell-style placeholders like
${AWS_REGION},${AWS_ACCOUNT_ID},${ACCOUNT_ID},${REGION}. IAM AccessAnalyzer treats them as literal values and rejects the policy at deploy time with "The Region X is not valid for this resource" or "The account X is not valid." Valid forms:- Any region / any account: use
*wildcard. - Scoped via IAM policy variables:
${aws:RequestedRegion}(region),${aws:PrincipalAccount}/${aws:ResourceAccount}(account). - Literal substitution:
us-east-1and123456789012with_metadata._deployment_notesexplaining the substitution.
Examples:
WRONG: "Resource": "arn:aws:<service>:${AWS_REGION}:${AWS_ACCOUNT_ID}:<type>/*" RIGHT (any region, any account): "Resource": "arn:aws:<service>:*:*:<type>/*" RIGHT (scoped via IAM policy variables): "Resource": "arn:aws:<service>:${aws:RequestedRegion}:${aws:PrincipalAccount}:<type>/*" RIGHT (literal substitution with deployment note): "Resource": "arn:aws:<service>:us-east-1:123456789012:<type>/*" "_metadata": {"_deployment_notes": "Replace us-east-1 and 123456789012 with your account's region and ID."}The shell-style
${AWS_REGION}pattern is a harness-substitution convention only — it belongs in_deployment_notesprose (instructional text), never in Resource ARN values, Condition key values, or any string IAM parses as policy content.- Any region / any account: use
MANDATORY — Include ALL applicable actions per condition key: When a condition key applies to multiple actions (e.g., both
Create*andUpdate*), the SCP statement MUST include ALL actions from the condition key'sapplies_to_actions[]in validated.json. Only covering Create* leaves Update* unprotected — an attacker could modify existing resources to remove security controls. Readcapabilities.iam.condition_keys[].applies_to_actionsfor every condition key used and include the full action set.Tag enforcement MUST cover UntagResource: SCPs that enforce mandatory tags on Create* operations MUST also deny
UntagResourcefor those tag keys usingaws:TagKeyscondition. Without this, mandatory tags can be removed after resource creation. Add a separate Deny statement:"Action": "<prefix>:UntagResource"with"Condition": {"ForAnyValue:StringEquals": {"aws:TagKeys": ["Owner", "CostCenter", "Environment", "DataClassification"]}}.Use
"NotAction"only for region-restriction controlsUse
"Principal": "*"only in resource-based policies, NOT in SCPsTrack the minified policy body size (Version + Statement, excluding
_metadata); split across multiple files at 5120 bytes. AWS Organizations' SCP limit applies to the minified JSON submitted to the API, not the pretty-printed file. Compute before writing:len(json.dumps({k:v for k,v in policy.items() if k!='_metadata'}, separators=(',',':')))MANDATORY BreakGlass exclusion:
"ArnNotLike": {"aws:PrincipalARN": ["arn:*:iam::*:role/BreakGlassRole"]}in EVERY Deny statement. Only exception:DenyProhibitedTagKeys(PII patterns).AND-logic awareness: Conditions in one block use AND. If exceptions should be independent (OR), use SEPARATE statements.
Service-principal vs confused-deputy keys MUST live in separate statements:
aws:PrincipalServiceNameis set ONLY when an AWS service is the calling principal;aws:SourceAccount/aws:SourceArnare cross-service confused-deputy keys set on the target call FROM the service. They never co-occur in the same authorization context, so AND-ing them in one Condition block makes the Deny unreachable. Split into two statements: one keyed onaws:PrincipalServiceNamefor service-as-caller paths, one keyed onaws:SourceAccount/aws:SourceArnfor confused-deputy paths.// WRONG — conditions AND together; deny never fires because the two keys // are never present in the same authorization context. { "Effect": "Deny", "Action": "sts:AssumeRole", "Condition": { "StringEquals": {"aws:PrincipalServiceName": "<service>.amazonaws.com"}, "StringNotEquals": {"aws:SourceAccount": "${aws:PrincipalAccount}"} } } // RIGHT — two statements, each keyed on exactly one mechanism. The first // covers the service-as-caller path; the second covers the confused-deputy // path. Either path alone fires the Deny. { "Sid": "DenyServicePrincipalUnscoped", "Effect": "Deny", "Action": "sts:AssumeRole", "Condition": { "StringEquals": {"aws:PrincipalServiceName": "<service>.amazonaws.com"} } }, { "Sid": "DenyConfusedDeputyAcrossAccount", "Effect": "Deny", "Action": "sts:AssumeRole", "Condition": { "StringNotEquals": {"aws:SourceAccount": "${aws:PrincipalAccount}"} } }
No non-standard fields in Statements: IAM policy Statements only accept standard
keys (Sid, Effect, Principal, NotPrincipal, Action, NotAction, Resource,
NotResource, Condition). Do NOT add _description, _comment, _control_ids, or
any other underscore-prefixed metadata inside Statement objects — IAM Access Analyzer
and the SCP API will reject them. Put per-statement documentation in the Sid field
(e.g., "Sid": "DenyUnencryptedCreation") or as _metadata at the top level only.
Format:
{ "_metadata": { ... }, "Version": "2012-10-17", "Statement": [ ... ] }
SCP validation (JSON syntax and 5120-byte minified policy body limit, excluding
_metadata) runs automatically via PostToolUse hook (validate_controls.py
Checks 7, 12).
SCP simulation metadata (REQUIRED for every SCP): Each SCP JSON MUST include a
_simulation_tests field in the _metadata object. This tells a downstream test
harness what actions to simulate as denied and what actions should remain allowed.
{
"_metadata": {
"framework": "...",
"controls": ["CTRL-..."],
"_simulation_tests": {
"should_deny": [
{"action": "lambda:CreateFunction", "context": {"lambda:CodeSigningConfigArn": ""}}
],
"should_allow": [
{"action": "lambda:CreateFunction", "context": {"lambda:CodeSigningConfigArn": "arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-abc"}}
]
}
},
"Version": "2012-10-17",
"Statement": [...]
}
The should_deny list includes actions + context values that the SCP should block.
The should_allow list includes actions + context values that should pass.
A test harness exercising SCPs typically creates an ephemeral test role, attaches
the SCP as an IAM policy, and calls iam:SimulatePrincipalPolicy to verify the
deny/allow behavior matches the test vectors in _simulation_tests.
AI Opt-out Policy (preventive/ai-optout-policy.json)
Only if ai_optout_applicable: true.
Account × Proactive
Rule C3 — API-driven mechanism contents (REQUIRED for every artifact below)
CFN Guard rules, CFN Hooks, Checkov policies, OPA/Rego rules, Terraform variable
validation blocks, and CDK runtime validation are API-driven mechanisms — they
evaluate IaC/template contents before any AWS API call. Their contents MUST be grounded
in validated.json.api_surface.operations[].parameters[]:
Every property path referenced by a rule MUST correspond to a parameter in
api_surface.operations[].parameters[]. Do not invent property paths; copy thepathfield verbatim (strip the$.prefix if the target DSL uses dotted notation).CFN Guard caveat — API path ≠ CFN property path. When the rule targets a CloudFormation resource, the API
pathfromparameters[]is NOT authoritative for the rule. The CFN template schema is a separate surface: AWS can (and does) expose the same setting at a different resource type and under a different property name than the underlying API operation. Example: Fargate ephemeral-storage KMS appears in the RunTask API, but the CFN schema places it atAWS::ECS::Cluster.Properties.Configuration.ManagedStorageConfiguration.FargateEphemeralStorageKmsKeyId—AWS::ECS::TaskDefinition.EphemeralStorageonly acceptsSizeInGiB. Before asserting<Path>.<Prop> existsin a Guard rule, verify the property is actually declared on that resource type in the CFN Template Reference (https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/AWS_{cfn_prefix}.html) or viaawsiacMCP. An assertion on a non-existent property is doubly broken: the rule can never pass a real template, AND a "compliant" template written to satisfy the rule fails cfn-lint E3002 (additional properties not allowed).Every enum-literal list (
in [...],AllowedValues: [...],validation {...},contains([...], var.x)) MUST use exact values fromparameters[].enum. Do NOT re-scrape AWS API reference docs at generate time — the enum values invalidated.jsonare the single source of truth. Copy them verbatim. Common failure: substitutingIAMforIAM_AUTH,CustomJWTforCUSTOM_JWT_AUTHORIZER, or inventing camelCase/PascalCase variants — all cause the rule to silently not fire.Every min/max/pattern constraint (TF
validation, CFNMinValue/MaxValue/AllowedPattern) MUST come fromparameters[].min/parameters[].max/parameters[].pattern.
Validator CHECK-14b enforces rule 2 at hook time by scanning the artifact body for
enum-style literal lists and verifying every uppercase-literal value exists in
parameters[].enum. A literal value that is not in any parameter's enum fails the
hook. The check is scoped to API-driven files only — preventive/ SCP-style files are
not evaluated by CHECK-14b (they are evaluated by CHECK-14a).
CloudFormation Guard Rules (proactive/cfn-guard-rules.guard)
Cross-validation constraint: Every Guard rule MUST pass when evaluated against the compliant CloudFormation template generated by generate-iac. The consistency check is:
cfn-guard validate --rules proactive/CTRL-*.guard --data iac/cloudformation/*.cfn.yaml
If a guard rule flags a violation in the compliant template, either the guard rule uses an incorrect property path or the CFN template is missing a required property. Both must be fixed together — trace to the source of the discrepancy.
Use
awsiacMCP for valid cfn-guard syntax per resource typeEvery rule MUST have a control ID: rule name includes control ID. Example:
rule enforce_encryption_ctrl_acc_pro_001 { ... }(format example — NOT a literal ID). Do NOT generate "bonus" rules that are not mapped to any CTRL-* control. If a check is needed but has no control, either assign it to an existing control or omit it. Unmapped rules create audit confusion — every rule must trace back to the controls catalog.Mapping-driven ID lookup (REQUIRED — do not invent IDs): Before writing ANY artifact, load
.service-approval/<slug>/04-map/mapping-results.jsonand find the control whosemechanismmatches the artifact type you are generating. Use the EXACTidfield from that control, verbatim:- Rule/policy names (Guard, OPA, Checkov): lowercase the ID, convert hyphens to underscores
(e.g.,
CTRL-ACC-PRO-001→ctrl_acc_pro_001) - JSON/YAML
_metadata.controls, CFN headers, comments: uppercase form verbatim (CTRL-ACC-PRO-001) - The ID prefix (
ACC/ORG/RES) is determined by mapping's mechanism→scope table — do NOT override based on a section heading in this skill. - If no control in mapping-results.json has a matching mechanism, do NOT generate the artifact. Pattern-matching an ID from a previous service's run is a bug.
- Rule/policy names (Guard, OPA, Checkov): lowercase the ID, convert hyphens to underscores
(e.g.,
One artifact per mechanism per control: Each mapped control represents a distinct enforcement mechanism. If mapping has N controls whose
mechanismfield differs (e.g., one KMS Key Policy, one IAM Trust Policy, one VPC Endpoint Policy, one S3 Bucket Policy), generate N separate artifacts — do NOT collapse them into a single file just because they share a category/scope prefix. Collapsing loses the control-to-file traceability the control-to-file validator enforces (seevalidate_controls.pyCHECK-13).Valid Guard 2.x syntax only: Do NOT use
%size()function — it is not standard CFN Guard 2.x syntax. For collection size checks, usesomeclauses,[N] EXISTSchecks, or iterate withwhenblocks. Example: check subnet count ≥ 2 withProperties.Subnets[1] EXISTS.Unique
letvariable names per resource type: Eachlet foo = Resources.*[ Type == ... ]block MUST use a UNIQUE variable name across the whole .guard file. cfn-guard does NOT overwrite a previously declaredlet— redeclaring the same name parses but produces unpredictable rule dispatch across resource types. For a service with N CFN resource types, emit N uniquely-named variables:let {service}_{type_slug_1}_resources = Resources.*[ Type == 'AWS::{Service}::{Type1}' ] let {service}_{type_slug_2}_resources = Resources.*[ Type == 'AWS::{Service}::{Type2}' ] ... let {service}_{type_slug_N}_resources = Resources.*[ Type == 'AWS::{Service}::{TypeN}' ]Placeholders:
{service}—sar-facts.json.cfn_prefix, lowercased. Never hardcode.{Service}— the CFN namespace (PascalCase, e.g.Lambda,S3,BedrockAgentCore).{TypeN}— each CFN resource-type suffix the target service exposes, as listed in its CFN Template Reference page. Enumerate ALL exposed types; do not curate.{type_slug_N}— the CFN type suffix, lowercased with non-alphanum replaced by_.
The count and names of resource types come entirely from the target service's CFN model — this SKILL must never assume a fixed set. For a service with 1 CFN type, emit 1 variable; for 10, emit 10.
Rule bodies MUST contain at least one enforceable clause — not just a placeholder comment like
# Check parameters: .... cfn-guard rejects comment-only rule bodies inside awhen ... { <resource_ref> { ... } }block with a parse error (Parser Error ... fragment %<var> !empty { ... }). If a control's security intent cannot be expressed as a concrete property-level check (common for tag-array membership and complex conditional logic), DO NOT emit an empty-body Guard rule. Route the control to OPA/Rego (which can express richer set-membership logic) or to CFN Hooks (which run arbitrary lambda code) instead. Leaving a placeholder comment produces a broken .guard file that failscfn-guard parse-treeat smoke-test time.Block-level resource filtering — use
Resources.*[ Type == "X" ], NEVERResources[this] when this.Type: To iterate all Resources of a given type, use the block-level filterResources.*[ Type == "AWS::X::Y" ] { ... }. The token[this]paired withwhen this.Type == "..."is NOT a valid selector in cfn-guard 3.x — it hard-errors withParser Error ... fragment [this] when this.Type(exit 5). If the rule already has awhen Resources[*].Type == "AWS::X::Y"guard at the rule level, the block body should assert on Properties without re-filtering by Type. Ref: https://docs.aws.amazon.com/cfn-guard/latest/ug/writing-rules.htmlRelational ops on intrinsic values crash cfn-guard: cfn-guard cannot compare an intrinsic-function value (
!Ref,!GetAtt,!Sub,!ImportValue,!FindInMap,!Join) against ANY scalar literal — it hard-errors withComparisonError: PathAwareValues are not comparable map, <type>(exit 19). This applies to every relational operator, not just string equality:- String:
!=/==— error[map, String] - Numeric:
>/</>=/<=/!=/==— error[map, int] - Boolean:
!=/==— error[map, Bool]
Compliant templates routinely wire KMS ARNs, role ARNs, log-group names, and numeric thresholds through intrinsics (parameter overrides, stack outputs, SSM parameter lookups), so any relational comparison becomes a silent timebomb: it fails only when the template is parameterized, which is exactly the production case. Reachability-masked bugs (rules inside a
whenpath the compliant template never triggers) compound the problem.- Scalar present and non-null →
Properties.X exists - Collection present with ≥1 element →
Properties.X !empty(collection-only — it cannot test an empty string) - Enum-style membership on a string or number →
Properties.X in [...](only when X is a literal in the compliant template — confirm by reading the template before writing the rule) - Only use
!= ""/!= null/> 0/< Nwhen the value is a literal in every compliant template for this service. When in doubt, useexistsand push the threshold check to Checkov / OPA / Config Rule, which accept intrinsics as opaque refs.
Ref: https://docs.aws.amazon.com/cfn-guard/latest/ug/writing-rules.html (
exists,empty, operator semantics).- String:
Array iteration
[*] in [...]must be guarded by!empty: cfn-guard 3.x raisesRequiredPropertyError(exit 19) when evaluatingProperties.X[*] in [...]against an empty array. This produces a FALSE-POSITIVE FAIL on compliant templates where the array is legitimately empty (e.g.,Capabilities.Add: []). Wrap the iteration in awhen ... !emptyclause:# WRONG — FAILS when Add is []: rule r { AWS::ECS::TaskDefinition { Properties.ContainerDefinitions[*].LinuxParameters.Capabilities.Add[*] in ["OK"] }} # CORRECT — skips the iteration when the array is empty: rule r { AWS::ECS::TaskDefinition { Properties.ContainerDefinitions[*].LinuxParameters.Capabilities { when Add !empty { Add[*] in ["OK"] } } }}The rule correctly treats "array is absent or empty" as "nothing to violate" rather than an iteration error.
Authorizer field completeness: When validating authorizer controls, guard rules MUST check ALL accepted configuration fields. Verify the exact set of accepted fields via
aws <service> <command> --generate-cli-skeleton— do NOT assume fields exist based on documentation alone. Only generate rules for fields the API actually accepts.Enum value accuracy: Guard rules, OPA policies, and Checkov custom policies MUST use the EXACT enum values for each parameter. Before writing any enum comparison:
- Find the parameter in
validated.jsonapi_surface.operations[].parameters[]and use the values inparameters[].enum. - Also consult the relevant mitigation's
feasibility_noteand the parameter'senrichment_opportunities[]/condition_keys[]— these sometimes list acceptable values the raw enum does not. - Use the values verbatim — do NOT invent camelCase or SCREAMING_SNAKE_CASE transforms
(e.g.,
IAM_AUTH≠IAM,awsvpc≠AWSVPC). A wrong enum silently neutralises the rule (Guard skips, OPA passes false, Checkov never triggers).
CHECK-14b enforces SCREAMING_SNAKE
[...]list-form membership at hook time. Equality-form (== "..."/!= "..."), pure-miss lists, and camelCase values are NOT hook-checked — they remain your responsibility.- Find the parameter in
CloudFormation Hooks Policy (proactive/cfn-hooks-policy.json)
- Use
awsiacMCP to confirm resource type supports CFN Hooks - Header completeness: The
CONTROLSheader MUST list ALL proactive controls, not just a subset. The hook executes the Guard file which implements all proactive controls, so the hook header must claim all of them.
Checkov Config (proactive/checkov-config.yaml)
- Check IDs mapping to controls in this cell
- Custom policy directory: If any custom check IDs are referenced (e.g.,
CKV_AWS_<SERVICE>_*), thecustom-policies-dirMUST point to a directory that exists and contains the actual policy definitions. Createproactive/custom-policies/and write a.pyor.yamlpolicy file for each custom check. A Checkov config referencing non-existent custom policies will fail at runtime.
Each custom policy file must define the check ID, resource type, and evaluation logic.mkdir -p .service-approval/<slug>/05-generate/proactive/custom-policies
OPA Account Policies (proactive/opa-policies/<ResourceType>.rego)
- One
.regofile per resource type - Authorizer field completeness: When validating authorizer controls, OPA deny rules
MUST check ALL accepted configuration fields. Verify the exact set via
aws <service> <command> --generate-cli-skeleton. Generate separate deny blocks for each required field — do not generate checks for fields the API does not accept.
Access-log exemption tag contract (S3)
The CTRL-ACC-PRO-002 rule (require aws_s3_bucket_logging companion on every
aws_s3_bucket) MUST provide an exemption for log-target buckets. Log sink buckets
cannot log to themselves — the companion requirement would force a recursion. The
canonical exemption is a tag with key "bucket-role" and value "log-target",
checked by a predicate named is_log_target(tags):
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
not has_logging
not is_log_target(rc.change.after.tags)
msg := sprintf("CTRL-ACC-PRO-002: aws_s3_bucket %v requires logging (aws_s3_bucket_logging) unless tagged bucket-role=log-target", [rc.address])
}
is_log_target(tags) if {
tags["bucket-role"] == "log-target"
}
generate-iac emits this exact tag on any generated access-log sink bucket (see
generate-iac/SKILL.md "Logging target buckets (S3)"). If you change the tag key,
the tag value, or the predicate name here, update generate-iac/SKILL.md in lockstep —
both skills must agree.
Plan-time unknown attributes (Terraform after_unknown)
When an OPA rego deny rule checks a Terraform resource attribute value that may
reference a sibling resource being created in the same plan (e.g.,
kms_master_key_id = aws_kms_key.bucket.arn where the KMS key is net-new), the rule
MUST accept both forms Terraform emits:
- Known at plan time — attribute value present in
change.after. - Known after apply — attribute value absent from
change.after, recorded inchange.after_unknownastrue. This happens whenever an attribute references a sibling resource being created in the same plan — typically: KMS keys, SNS topics, CloudWatch log groups, IAM roles, security groups, VPC endpoints, S3 buckets.
Without the after_unknown branch, the rego incorrectly denies every fresh
terraform plan where the referenced resource is created inline. The rule is
service-agnostic: apply the two-branch pattern to ANY rego rule that checks a
cross-resource reference attribute.
Helper pattern (pass the whole rc.change, not just rc.change.after, so both
branches are reachable):
# Example: CTRL-ACC-PRO-001 SSE-KMS rule
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_server_side_encryption_configuration"
not has_kms(rc.change)
msg := sprintf("CTRL-...: %v must use aws:kms with a CMK", [rc.address])
}
# Branch 1: concrete kms_master_key_id in `after`
has_kms(change) if {
some rule in change.after.rule
some sse in rule.apply_server_side_encryption_by_default
sse.sse_algorithm in {"aws:kms", "aws:kms:dsse"}
sse.kms_master_key_id != ""
}
# Branch 2: plan-time-unknown marker in `after_unknown`
has_kms(change) if {
some rule in change.after.rule
some sse in rule.apply_server_side_encryption_by_default
sse.sse_algorithm in {"aws:kms", "aws:kms:dsse"}
some unknown_rule in change.after_unknown.rule
some unknown_sse in unknown_rule.apply_server_side_encryption_by_default
unknown_sse.kms_master_key_id == true
}
Helper signatures MUST take rc.change (not rc.change.after) when they cover
attributes that could be cross-referenced. Existing rego rules using the old
has_X(after) signature continue to work for attributes never referenced across
resources; update signatures when migrating a rule to support cross-referenced
attributes.
Account × Preventive
Permission Boundary (preventive/permission-boundary.json)
Generate ONLY if mapping-results.json contains at least one control with
mechanism == "IAM Permission Boundary". If no such control exists, SKIP
this file entirely — do NOT emit a placeholder and do NOT invent a control ID
(e.g., CTRL-ACC-PRV-BOUNDARY). The resulting artifact must reference ONLY
control IDs present in mapping-results.
- Limit role permissions to needed service actions
- Use
awsknowledgeMCP for real action names - Globally-scoped IAM actions MUST be in their own Statement with
Resource: "*": Some IAM actions (notablyecr:GetAuthorizationToken,sts:GetCallerIdentity,sts:AssumeRoleagainst account-wide scope,cloudtrail:DescribeTrailswhen service-wide) do NOT accept resource-level permissions and must be grantedResource: "*". Grouping them in a Statement whoseResourcelists ARNs (e.g.,arn:aws:ecr:{region}:{account}:repository/*) produces a boundary that silently denies them because AWS evaluates per-action resource compatibility. Split such actions into their own Allow Statement withResource: "*"and document the reason in aSid. Verify against the SAR page: actions whose Resource types column is empty are global-only and cannot be scoped.
IAM action validation (CRITICAL): Before generating any IAM policy artifact (SCP,
permission boundary, IAM policies, resource policies, VPCE policies), verify that every
Action value exists in validated.json api_surface.operations[].operation OR is listed
as a permission-only action in capabilities.iam.permission_only_actions[]. Permission-only
actions (e.g., InvokeGateway) are valid IAM-evaluated actions without API endpoints — they
CAN be used in policies. Common error: inventing action patterns (e.g., <prefix>:Describe*)
when the service only supports Get* and List* for read operations. Check the service's
IAM actions via awsknowledge MCP or
the IAM Actions Reference. If the service prefix does not support a particular action verb,
do not generate it. This applies to ALL policy files: scp-policy.json, iam-policies.json,
permission-boundary.json, resource-policy.json, vpce-policy.json.
IAM Policies and Trust Policies (preventive/iam-policies.json)
Generate ONLY if mapping-results.json contains at least one control with
mechanism ∈ {"IAM Policy", "IAM Trust Policy"}. If no such control exists,
SKIP this file entirely — do NOT emit a placeholder and do NOT invent a control
ID (e.g., CTRL-ACC-PRV-EXEC-ROLE). The controls field of _metadata must
reference ONLY IDs present in mapping-results.
- Generate standalone IAM policy and trust policy documents for ACC-PRV controls with
mechanismof "IAM Policy" or "IAM Trust Policy" - Each control gets a named policy document in the JSON file
- Trust policies: include
Conditionblock for confused deputy protection - IAM policies: scope to specific resource ARN patterns, not
* - Do NOT skip IAM Policy / Trust Policy controls that ARE in mapping-results — they are distinct from the permission boundary and implement specific scoped access patterns (execution role policies, trust relationships). A mapped IAM-Policy-mechanism control with no standalone artifact is a generation gap. (This directive catches mapper omissions of mapped controls; it does NOT sanction inventing control IDs when mapping is empty — see the gate above.)
Resource × Proactive
Scope is determined by mapping-results.json, NOT by this heading. CloudFormation Guard rules map to scope=ACC by default per the map-generate-controls mechanism→scope table (they run at the account's CFN deploy or as account-scoped Hooks, not attached to an individual resource ARN). A RES-PRO control exists only if the mechanism is genuinely resource-attached (rare for Guard — more typical for mechanisms like resource-based policies that also enforce at create time).
Before generating any artifact in this section, look up the control in
mapping-results.json. If its scope is ACC, emit the rule under the Account × Proactive
section instead — do NOT force-write a CTRL-RES-PRO-* ID just because this section is
titled "Resource × Proactive".
If resource-scoped proactive controls DO exist in mapping, append them to
proactive/cfn-guard-rules.guard with a comment header separating them from account-level
rules and use the control's exact mapping ID for the rule name.
Resource × Preventive
Resource Policy (preventive/resource-policy.json)
Inclusion is data-driven, not a hardcoded service list. Three branches based
on validated.json.capabilities.iam.resource_policies_supported AND
mapping-results.json:
Branch 1 — Service supports RBP AND mapping has a control:
Generate the full policy. Required when
resource_policies_supported: true AND mapping-results.json contains at least
one control with mechanism ∈ {"Resource Policy", "Resource-Based Policy"}.
Branch 2 — Service supports RBP but no control is mapped:
SKIP the file entirely. Do NOT emit a placeholder; do NOT invent a control ID
(e.g., CTRL-RES-PRV-RESPOL). The mapping is the authoritative source of what
to generate.
Branch 3 — Service does NOT support RBP:
When resource_policies_supported: false, emit a NOT_APPLICABLE stub so that
(a) CHECK-X2 control-coverage passes for any RBP-intended control that couldn't
be rendered, (b)
…(truncated)