infraweaver 0.3.10 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/quotaTokens.d.ts +8 -0
  3. package/dist/cli.mjs +1843 -1601
  4. package/dist/index.js +421 -182
  5. package/dist/internal.js +6 -4
  6. package/dist/mcp/crosswalk-digest.d.ts +1 -1
  7. package/dist/mcp/git.d.ts +19 -3
  8. package/dist/mcp/localContext.d.ts +9 -1
  9. package/dist/mcp/shell.d.ts +3 -3
  10. package/dist/mcp/staleFix.d.ts +13 -1
  11. package/dist/mcp/terraform/hardcodedSecrets.d.ts +49 -0
  12. package/dist/mcp/terraform/nativeRules.d.ts +1 -0
  13. package/dist/mcp/terraform/refactorScope.d.ts +12 -0
  14. package/dist/mcp/terraform/tools/scan.d.ts +1 -0
  15. package/dist/mcp/terraform/types.d.ts +3 -0
  16. package/dist/utils/config/payload.d.ts +2 -0
  17. package/package.json +1 -1
  18. package/src/agents/claude.ts +2 -1
  19. package/src/agents/opencode.ts +2 -1
  20. package/src/agents/quotaTokens.ts +25 -0
  21. package/src/mcp/crosswalk-digest.ts +1 -1
  22. package/src/mcp/crosswalk.ts +2 -0
  23. package/src/mcp/git.ts +44 -3
  24. package/src/mcp/localContext.ts +8 -0
  25. package/src/mcp/localServer.ts +3 -0
  26. package/src/mcp/moduleExtraction.ts +4 -1
  27. package/src/mcp/shell.ts +2 -2
  28. package/src/mcp/staleFix.ts +21 -2
  29. package/src/mcp/terraform/concernResult.ts +24 -4
  30. package/src/mcp/terraform/hardcodedSecrets.ts +202 -0
  31. package/src/mcp/terraform/nativeRuleScanners.ts +8 -0
  32. package/src/mcp/terraform/nativeRules.ts +14 -0
  33. package/src/mcp/terraform/refactorScope.ts +86 -0
  34. package/src/mcp/terraform/standardsReport.ts +2 -0
  35. package/src/mcp/terraform/tools/consolidationCandidates.ts +5 -1
  36. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -1
  37. package/src/mcp/terraform/types.ts +3 -0
  38. package/src/modes/refactor.ts +2 -0
  39. package/src/modes/refresh-remediation.ts +1 -1
  40. package/src/modes/remediate-and-refactor.ts +1 -1
  41. package/src/modes/remediate.ts +2 -2
  42. package/src/utils/config/payload.ts +4 -0
package/dist/internal.js CHANGED
@@ -1781,6 +1781,8 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR \u2014 the equivalen
1781
1781
  - \`${t("terraform_normalization_candidates")}\` \u2192 in-place idiomatic cleanups that change SYNTAX, not behaviour (redundant whole-string \`"\${expr}"\` interpolation; legacy HCL0.11 map-argument block syntax like \`vars {\` on a \`template_file\`). These relocate NOTHING \u2014 zero \`moved {}\` blocks \u2014 and are proven by the same equivalence check.
1782
1782
  - \`${t("terraform_consolidation_candidates")}\` \u2192 the SAME resource shape declared in several root directories (\`env/dev\` + \`env/staging\` + \`env/prod\`), with the single parameterised module they could all call already derived: which attributes are identical (module content) and which differ (per-environment inputs). A member root of a group will ALSO show up as a zero-candidate cluster in \`${t("module_extraction_candidates")}\` \u2014 prefer the consolidation, which does that work once for every environment instead of once per environment. Its \`near_groups\` are the opposite case: roots recognisably the same stack that DIFFER (one carries a resource the other does not), where folding them together is a design decision this mode has no basis for. Do NOT extract a near-group root's shared resources into a module on its own either \u2014 a module one twin calls and the other does not widens exactly the drift the near-group reports. Leave those roots alone and name the near-group and its \`differences\` in your report.
1783
1783
 
1784
+ **A result marked \`report_only\` is an observation, not work.** The repository is a module other configurations call (\`report_only_reason\` says why), and restructuring it is churn for every caller: name those candidates in your \`${t("report_progress")}\` summary and open no PR for them. \`examples/\` roots are left out of every candidate list unless the operator set \`refactor_examples\`.
1785
+
1784
1786
  **Before picking, check the interface.** \`${t("module_extraction_candidates")}\` reports \`unmapped_attributes\` per candidate and a top-level \`partial_interfaces\` list: arguments the raw resources SET that the target module does not. Adopting such a module DROPS them silently \u2014 and the equivalence check cannot catch it, because once the argument is gone it is absent from both sides. Two of these are unrecoverable rather than merely wrong (\`object_lock_enabled\` cannot be set after creation; \`force_destroy\` changes deletion semantics). So: **decline the candidate, or extend the module to expose the argument and say so in the PR body.** Deleting the argument to make the shapes match is never the fix. A candidate whose \`unmapped_attributes\` is \`null\` was NOT checked \u2014 the module is external and its code is not in this repo \u2014 which is not the same as clean.
1785
1787
 
1786
1788
  Pick the highest-value refactor across ALL THREE detectors. Only when they ALL come back empty \u2014 or all that remains is a cluster in a near-group root \u2014 is there genuinely nothing to do: call \`${t("report_progress")}\` with an ACCURATE message \u2014 e.g. "No behaviour-preserving refactor found: no extraction clusters and no idiomatic-normalisation candidates." \u2014 and **stop**. Two anti-patterns to avoid in that message: (i) never call a repo "already idiomatic" while normalisation candidates remain; (ii) when what's left is behaviour-CHANGING modernisation \u2014 a deprecated-resource swap (\`aws_launch_configuration\`\u2192\`aws_launch_template\`, \`aws_elb\`\u2192\`aws_lb\`, \`template_file\`\u2192\`templatefile()\`) \u2014 do NOT report "nothing to refactor": that is real work, but it ALTERS the resource set, so it belongs to Remediate. Say so explicitly rather than implying the code is clean.
@@ -1867,7 +1869,7 @@ This mode keeps already-open ${PRODUCT_NAME} remediation PRs healthy. A remediat
1867
1869
  - **resolved-on-base? \u2192 close it**: look for a group in step 3's scan whose \`id\` equals the PR's \`group_id\` (for a by-rule/batch PR, match on the concern ids it covered). If **no** current group/concern corresponds, the concern was already fixed on the base (a human fix, or a base change removed the file) \u2014 the PR is redundant. Call \`${t("close_pull_request")}\` with a one-line \`comment\` explaining it's resolved on the base. Do not push anything for this PR.
1868
1870
  - **still present \u2192 re-derive the fix on the current base**:
1869
1871
  - **read WHY the CI failed, before changing anything** (\`checks_failing\` PRs only): when step 2 gave the PR a \`failing_check_suite_id\`, call \`${t("get_check_suite_logs")}\` with that id FIRST. Re-deriving without reading the failure is exactly how attempt 2 and attempt 3 become identical repeats of attempt 1 \u2014 same fix, same red check, three times, until the retry cap escalates a PR nobody learned anything about. Use the returned \`failed_steps\` and log excerpt to decide what to do DIFFERENTLY this attempt. If the log shows the failure is not ours \u2014 a broken base, a missing credential, an unrelated flaky job \u2014 do not spend an attempt re-deriving: add \`needs-human\` (\`${t("add_labels")}\`), post ONE comment naming the failing step, and move on. Record the cause in the PR body/comment you write below, so the next attempt and any human start from what you found. When the id is absent, or the tool reports no failed runs, say so in the comment and continue with the plain re-derive rather than guessing.
1870
- - **branch**: recreate the remediation branch at the current base HEAD via \`${t("git")}\` (\`git checkout -B remediate/<id>\`) \u2014 \`-B\` force-resets it to the just-scanned base so the diff is only your fix.
1872
+ - **branch**: recreate the remediation branch at the current base HEAD via \`${t("git")}\` (\`git({ command: "checkout", args: ["-B", <the PR's branch>] })\`, the \`branch\` step 2 returned) \u2014 \`-B\` force-resets it to the just-scanned base so the diff is only your fix.
1871
1873
  - **fix \u2192 validate \u2192 plan \u2192 keep tests consistent \u2192 prove it**: apply the minimal fix for that group exactly as in **Remediate** step 4 (same \`${t("terraform_validate")}\`, \`${t("terraform_plan")}\`, \`${t("terraform_module_tests")}\`, and \`${t("terraform_verify_remediation")}\` gates, and the same guardrails \u2014 never open/keep a PR whose validate didn't pass, abandon a group that would destroy a stateful resource, etc.).
1872
1874
  - **force-update the PR branch**: \`${t("push_branch")}\` with \`force: true\` (the PR already exists; force-updating its branch refreshes it in place \u2014 do NOT open a second PR). The Terraform-only / secret / destroy guardrails still run at push time.
1873
1875
  - **refresh the body**: \`${t("update_pull_request_body")}\` rebuilt from the fresh \`${t("terraform_verify_remediation")}\` result (the Remediation PR format below), and add a one-line note that it was rebased onto the current base (\`<short-sha>\`) on this run.
@@ -1912,8 +1914,8 @@ function remediateMode(t) {
1912
1914
 
1913
1915
  4. **for the chosen group**:
1914
1916
  - **base branch**: this run's base branch is resolved deterministically \u2014 \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the branch the run started on, else the repository's default branch (\`main\`, or \`master\`). You do not choose it; just **omit** the \`base\` argument when opening the PR (below) and it is filled in.
1915
- - **idempotency**: the remediation branch is \`remediate/<group-id>\`. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
1916
- - **branch**: create \`remediate/<group-id>\` from the **current HEAD** (the checkout that was just scanned) via \`${t("git")}\` (\`git checkout -b remediate/<group-id>\`). Do NOT switch to a different base first \u2014 branching from the scanned checkout keeps the PR diff to exactly your fix.
1917
+ - **idempotency**: the remediation branch is the group's \`branch\` (\`remediate/<group-id>\`, with a \`--<base>\` suffix when the PR targets a non-default branch) \u2014 use it exactly, never a name of your own. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
1918
+ - **branch**: create the group's \`branch\` from the **current HEAD** (the checkout that was just scanned) via \`${t("git")}\` (\`git({ command: "checkout", args: ["-b", <branch>] })\`). Do NOT switch to a different base first \u2014 branching from the scanned checkout keeps the PR diff to exactly your fix.
1917
1919
  - **honest refusal (decide BEFORE fixing)**: if the group's concerns appear in the scan's \`refusal_candidates\` (the fix needs a human decision \u2014 narrowing an IAM wildcard, a KMS key policy, a real ingress CIDR), do **not** guess a fix that could break the stack. Instead open a structured issue (\`${t("create_issue")}\`) describing the concern, why it isn't auto-fixed, and what a human should do, and skip the PR for that group. A proven fix or an honest refusal \u2014 never a guessed, unverifiable PR.
1918
1920
  - **propose, then let me steer (when there's no single right fix)**: distinct from honest refusal (which refuses a fix a human must *decide*), this is for a finding with **2\u20133 genuinely distinct, defensible fixes** that differ in trade-offs, not correctness (e.g. encrypt with an AWS-managed key **vs** a customer-managed KMS key; a narrow security-group rule **vs** a prefix list **vs** a VPC endpoint). When such a fork exists **and the triggering comment did not already select a strategy**, do **not** silently pick for the reviewer: via \`${t("create_issue_comment")}\` post one short comment listing the options as **A / B / C** \u2014 each a single line (what it does + its trade-off) \u2014 and ask the reviewer to reply \`${COMMENT_COMMAND} fix #<concern-id> with strategy <A|B|C>\`. Then **skip the PR for this group** this run and note it in your final report (it resumes when the reviewer replies). When the comment **did** select one (\`fix #<id> with strategy B\`, or a bare \`strategy B\` reply on the proposal thread), apply **exactly** that strategy \u2014 don't second-guess it. Reserve this for real forks in the road; a fix with one obvious correct answer just gets made.
1919
1921
  - **reuse a proven fix (optional, do this BEFORE editing)**: call \`${t("terraform_fix_memory")}\`. When proven-fix memory is enabled it returns any patterns whose \`finding_type\` matches this group's concerns \u2014 each a transformation that already passed a \`\u2717\u2192\u2713\` proof on this repo or elsewhere in the fleet, with its \`remediation_hint\` + a \`before_example\`. Use a matching pattern as your STARTING POINT (it saves you rediscovering the approach), but ADAPT it to this repo's own resources \u2014 it is a prior, never a patch to paste. You MUST still run \`${t("terraform_verify_remediation")}\` to prove \`\u2717\u2192\u2713\` here; a pattern is never blind-applied and never substitutes for the per-target proof. Returns an empty list (never an error) when memory is off or nothing matches \u2014 then fix from first principles as usual.
@@ -1973,7 +1975,7 @@ This mode composes the **Remediate** and **Refactor** verbs into a single run. I
1973
1975
  - **commit the fix** (\`git add\` only the changed \`*.tf\`/\`*.tfvars\`, a \`fix(tf): \u2026\` message). Do NOT push yet. This committed state is the refactor's equivalence BASELINE.
1974
1976
 
1975
1977
  3. **REFACTOR (phase 2) \u2014 modularise on top of the committed fix**: follow the **Refactor** mode's flow:
1976
- - \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` \u2192 pick one behaviour-preserving refactor. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
1978
+ - \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` \u2192 pick one behaviour-preserving refactor; a result marked \`report_only\` (the repository is a module others call) is named in the summary, never proposed. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
1977
1979
  - resolve the module source, wire the \`module\` call against its real interface (\`${t("terraform_module_interface")}\` for a local module dir, \`${t("terraform_module_lookup")}\` for a registry-sourced one), and emit a \`moved {}\` block for EVERY relocated address (\`${t("terraform_generate_moved")}\`).
1978
1980
  - \`terraform fmt\` + \`${t("terraform_validate")}\`, then \u2014 **with the refactor edits still UNCOMMITTED** \u2014 call \`${t("terraform_equivalence_check")}\`. In this mode it diffs the working tree against the COMMITTED fix (not the run-start commit), so it measures only the refactor. It must return \`equivalent: true\` (zero uncovered moves, resource set + arguments preserved, validate + fmt clean) before you proceed; \`${t("push_branch")}\` hard-blocks an unproven one \u2014 and, once this run has added a \`moved {}\` block, it also hard-blocks a push that never ran the check at all, so abandoning the refactor means REVERTING its edits rather than leaving them in with the fix. If it can't be proven equivalent, abandon the refactor and ship the fix alone.
1979
1981
  - **document the module (only when the \`docs\` input is enabled)**: after the equivalence check passes, call \`${t("terraform_module_docs")}\` for the module you created/adopted (pass the relocated addresses as \`moves\`). Do not hand-write docs \u2014 the tool builds from the interface, moved blocks, and equivalence verdict.
@@ -1 +1 @@
1
- export declare const EXPECTED_CROSSWALK_DIGEST = "95afd7e402f0f1875fa0aac33aeee8b6b54f869ec65499fb727f515ee2101ee1";
1
+ export declare const EXPECTED_CROSSWALK_DIGEST = "f583001997c4c69f84f189a14e69e4f6eb2c52bddc04ca7f404286b5280d1ff4";
package/dist/mcp/git.d.ts CHANGED
@@ -44,14 +44,30 @@ export declare const NOSHELL_BLOCKED_WRITE_ARGS: string[];
44
44
  * `-o`, or attached `-o<path>`. */
45
45
  export declare function isBlockedWriteArg(arg: string): boolean;
46
46
  export declare const Git: import("arktype/internal/variants/object.ts").ObjectType<{
47
- command: string;
47
+ command?: string;
48
48
  args?: string[];
49
49
  }, {}>;
50
- export declare function GitTool(ctx: GitToolContext): import("fastmcp").Tool<any, import("@standard-schema/spec").StandardSchemaV1<{
50
+ /**
51
+ * The subcommand and arguments of a call, however the model wrote it: the
52
+ * subcommand alone (the documented form), a whole `git …` command line, or the
53
+ * subcommand as the first of `args`. Models wrote the other two often enough
54
+ * to cost a turn per run, and each is unambiguous. The subcommand is checked
55
+ * against the same pattern the schema uses, so a flag (`-c`, `-C`,
56
+ * `--exec-path`) can no more arrive through `args[0]` or a command line than
57
+ * through `command`.
58
+ */
59
+ export declare function normalizeGitCall(params: {
60
+ command?: string | undefined;
61
+ args?: string[] | undefined;
62
+ }): {
51
63
  command: string;
64
+ args: string[];
65
+ };
66
+ export declare function GitTool(ctx: GitToolContext): import("fastmcp").Tool<any, import("@standard-schema/spec").StandardSchemaV1<{
67
+ command?: string;
52
68
  args?: string[];
53
69
  }, {
54
- command: string;
70
+ command?: string;
55
71
  args?: string[];
56
72
  }>> & {
57
73
  mutates?: boolean;
@@ -13,7 +13,7 @@ import type { ResolvedPayload } from "../utils/config/payload.js";
13
13
  * needs more (octokit, push, PR state) belongs on `ToolContext`, not here.
14
14
  */
15
15
  export interface LocalToolContext {
16
- payload: Pick<ResolvedPayload, "cwd" | "scanScope" | "iacLanguages" | "severityThreshold" | "grouping" | "autonomyThreshold" | "repeatedLiteralThreshold" | "varFiles" | "costIncreaseBlockUsd" | "moduleCatalogue" | "allowReplace" | "allowMajorUpgrade" | "toolsEnabled" | "allowedPaths" | "toolsNetwork" | "gitleaks" | "terratest" | "docs" | "terraformMcp" | "moduleFetchToken" | "moduleFetchSshKey" | "registryToken" | "registryHost" | "opentofuMcp" | "fixMemory">;
16
+ payload: Pick<ResolvedPayload, "cwd" | "scanScope" | "iacLanguages" | "severityThreshold" | "grouping" | "autonomyThreshold" | "repeatedLiteralThreshold" | "varFiles" | "costIncreaseBlockUsd" | "moduleCatalogue" | "allowReplace" | "allowMajorUpgrade" | "toolsEnabled" | "allowedPaths" | "toolsNetwork" | "gitleaks" | "terratest" | "docs" | "terraformMcp" | "moduleFetchToken" | "moduleFetchSshKey" | "registryToken" | "registryHost" | "opentofuMcp" | "fixMemory" | "baseBranch" | "refactorExamples">;
17
17
  toolState: ToolState;
18
18
  tmpdir: string;
19
19
  /** resolved upstream model specifier driving this run's agent (e.g.
@@ -23,4 +23,12 @@ export interface LocalToolContext {
23
23
  * bundle's PROVENANCE block — recording WHO produced the evidence, not
24
24
  * just what was found. */
25
25
  resolvedModel?: string | undefined;
26
+ /** the repository, for its default branch. The Action's full `ToolContext`
27
+ * carries it; a local session has none, so its remediation branches keep the
28
+ * plain `remediate/<id>` name. */
29
+ repo?: {
30
+ data: {
31
+ default_branch?: string | null;
32
+ };
33
+ } | undefined;
26
34
  }
@@ -1,7 +1,7 @@
1
1
  import type { ToolContext } from "./server.js";
2
2
  export declare const ShellParams: import("arktype/internal/variants/object.ts").ObjectType<{
3
3
  command: string;
4
- description: string;
4
+ description?: string;
5
5
  timeout?: number;
6
6
  working_directory?: string;
7
7
  background?: boolean;
@@ -38,13 +38,13 @@ export declare function createCappedBuffer(limit: number): {
38
38
  };
39
39
  export declare function ShellTool(ctx: ToolContext): import("fastmcp").Tool<any, import("@standard-schema/spec").StandardSchemaV1<{
40
40
  command: string;
41
- description: string;
41
+ description?: string;
42
42
  timeout?: number;
43
43
  working_directory?: string;
44
44
  background?: boolean;
45
45
  }, {
46
46
  command: string;
47
- description: string;
47
+ description?: string;
48
48
  timeout?: number;
49
49
  working_directory?: string;
50
50
  background?: boolean;
@@ -2,8 +2,20 @@ import type { GitHubToolContext } from "./capabilityContext.js";
2
2
  /** true when `branch` is a Infraweaver remediation/generation branch. */
3
3
  export declare function isRemediationBranch(branch: string): boolean;
4
4
  /** the `<group-id>` of a `remediate/<group-id>` branch (the scan group id that
5
- * keys the fix), or null for a generation branch / non-remediation branch. */
5
+ * keys the fix), or null for a generation branch / non-remediation branch. A
6
+ * `--<base>` suffix (see [[remediationBranchSuffix]]) is not part of the id. */
6
7
  export declare function groupIdFromBranch(branch: string): string | null;
8
+ /**
9
+ * What a remediation branch name carries after its group id: nothing when the
10
+ * PR targets the repository default branch, `--<base>` otherwise.
11
+ *
12
+ * The group id is a hash of the file (or rule) alone, so the same fix aimed at
13
+ * two base branches — or at two repos whose roots are both `main.tf` — named
14
+ * the same branch, and a run that found it taken improvised another name the
15
+ * refresh and concurrency tools could not see. A default-branch fix keeps the
16
+ * plain name, so the PRs already open under it are still found and refreshed.
17
+ */
18
+ export declare function remediationBranchSuffix(base: string | undefined, defaultBranch: string | undefined): string;
7
19
  /** true when a commit-author LOGIN is Infraweaver's bot (so a commit by it is NOT
8
20
  * a human edit). A null/absent login is treated as non-human here for backward
9
21
  * compatibility of this login-only helper — but prefer {@link isBotCommit}, which
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A credential written into the configuration as a string literal, by the
3
+ * name of the argument that holds it.
4
+ *
5
+ * WHY BY NAME. The secret scanners look at the VALUE — a known key format, or a
6
+ * string with enough entropy — so a hand-made password (`"cg-flagshop-Pr0d!…"`)
7
+ * slips past all of them. Terraform says more than a text scanner can see: the
8
+ * value sits in an argument, a map key or a variable whose name says what it
9
+ * holds. A literal in `password`, `client_secret` or a Lambda environment
10
+ * variable called `auth` is in the repository whatever it looks like.
11
+ *
12
+ * WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
13
+ * `provider`, `module` or `locals` block, at any depth, so
14
+ * `environment { variables = { auth = "…" } }` and `jsonencode({ … })` objects
15
+ * are reached; and the `default` of a `variable` whose name is a secret's. Not
16
+ * read: comments, heredoc bodies, a name/value pair split across two keys
17
+ * (`[{ name = "PASSWORD", value = "…" }]`), and a value that starts on a later
18
+ * line than its name.
19
+ *
20
+ * WHAT IS NOT A FINDING. A reference, a function call, an interpolation, an
21
+ * empty string, or a placeholder made only of `*` / `x` / `<…>`. Neither is an
22
+ * argument whose name only mentions a secret (`password_length`, `secret_name`,
23
+ * `token_validity_units`, `creation_token`): the last word of the name decides.
24
+ *
25
+ * Arguments the provider can take write-only (`aws_db_instance.password`) may
26
+ * also be reported by `secret-persisted-to-state`, which says something else:
27
+ * that the value reaches state. This one says it is in git.
28
+ */
29
+ import type { SourceFile } from "./tree.js";
30
+ import { type Concern } from "./types.js";
31
+ /** True when an argument, map key or variable called `name` holds a secret. */
32
+ export declare function isSecretName(name: string): boolean;
33
+ export interface HardcodedSecret {
34
+ /** the argument, map key or variable name. */
35
+ name: string;
36
+ /** what holds it: `resource "aws_lambda_function" "x"`, `variable "db_password"`, … */
37
+ holder: string;
38
+ line: number;
39
+ }
40
+ /**
41
+ * Every secret-named literal in one file. Pure.
42
+ *
43
+ * One pass over the lines rather than a block parse: every assignment line is
44
+ * the same question whatever block it sits in, and the native pack's block
45
+ * parser is shared by every scanner and budgeted (see nativeScan.perf.test).
46
+ */
47
+ export declare function findHardcodedSecrets(hcl: string): HardcodedSecret[];
48
+ /** Scan an already-read tree into native concerns. Pure; no I/O of its own. */
49
+ export declare function scanHardcodedSecrets(files: readonly SourceFile[]): Concern[];
@@ -20,6 +20,7 @@ export interface NativeRule {
20
20
  }
21
21
  export declare const SECRET_PERSISTED_TO_STATE = "secret-persisted-to-state";
22
22
  export declare const COMMITTED_STATE_FILE = "committed-state-file";
23
+ export declare const HARDCODED_SECRET_ARGUMENT = "hardcoded-secret-argument";
23
24
  export declare const IMPERATIVE_ACTION_TRIGGER = "imperative-action-trigger";
24
25
  export declare const DYNAMIC_DESTROY_PROTECTION = "dynamic-destroy-protection";
25
26
  export declare const DEPRECATED_STATE_LOCKING = "deprecated-state-locking";
@@ -0,0 +1,12 @@
1
+ /** true for a path inside an `examples/` (or `example/`) directory, at any depth. */
2
+ export declare function isExamplePath(path: string): boolean;
3
+ /**
4
+ * Why the repository at `cwd` reads as a reusable module rather than a
5
+ * deployment, or null when it does not.
6
+ */
7
+ export declare function libraryRepoReason(cwd: string): string | null;
8
+ /** what a detector tool adds to its result when the repository is a library. */
9
+ export declare function reportOnly(reason: string | null): {
10
+ report_only?: true;
11
+ report_only_reason?: string;
12
+ };
@@ -42,6 +42,7 @@ export declare function runScan(ctx: LocalToolContext, { scan_scope, severity_th
42
42
  };
43
43
  groups: {
44
44
  id: string;
45
+ branch?: string;
45
46
  file: string;
46
47
  files?: string[];
47
48
  grouping?: "file" | "rule";
@@ -242,6 +242,9 @@ export interface ConcernGroup {
242
242
  /** stable id — the remediation branch/PR key (`remediate/<id>`). Derived from
243
243
  * the file (by-file grouping) or the rule (by-rule grouping). */
244
244
  id: string;
245
+ /** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
246
+ * PR targets a branch other than the repository default. */
247
+ branch?: string;
245
248
  /** the group's primary file (by-file) or a human label like "3 files"
246
249
  * (by-rule); `files` carries the full list for by-rule groups. */
247
250
  file: string;
@@ -64,6 +64,7 @@ export declare const Inputs: import("arktype/internal/variants/object.ts").Objec
64
64
  registry_token?: string | undefined;
65
65
  registry_host?: string | undefined;
66
66
  refactor_source?: string | undefined;
67
+ refactor_examples?: string | undefined;
67
68
  review_instructions?: string | undefined;
68
69
  fp_filtering_instructions?: string | undefined;
69
70
  remediation_instructions?: string | undefined;
@@ -151,6 +152,7 @@ managedCredentials?: {
151
152
  registryToken: string | undefined;
152
153
  registryHost: string | undefined;
153
154
  refactorSource: import("../prompt/promptDirectives.js").RefactorSource | undefined;
155
+ refactorExamples: boolean;
154
156
  remediationCommand: import("../github/remediationCommand.js").RemediationCommand | null;
155
157
  baseBranch: string | undefined;
156
158
  allowReplace: string[] | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infraweaver",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "GitHub Action that remediates Terraform to best practices and opens one scoped pull request per concern.",
5
5
  "keywords": [
6
6
  "ai-coding-agent",
@@ -60,6 +60,7 @@ import {
60
60
  getUnsubmittedReview,
61
61
  } from "#app/agents/postRun";
62
62
  import { buildGracePrompt, buildGraceQuotas, shouldRunGraceTurn } from "#app/agents/quotaReserve";
63
+ import { quotaTokens } from "#app/agents/quotaTokens";
63
64
  import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "#app/agents/reviewer";
64
65
  import { ORCHESTRATOR_LABEL, SessionLabeler, withLabel } from "#app/agents/sessionLabeler";
65
66
  import {
@@ -637,7 +638,7 @@ function timerFor(state: ClaudeRunState, label: string): ThinkingTimer {
637
638
  function chargeTokenQuota(state: ClaudeRunState): void {
638
639
  const quota = state.params.tokenQuota;
639
640
  if (!quota || state.stopRun.signal.aborted) return;
640
- if (!quota.observe(state.quotaSource, state.accumulatedTokens)) return;
641
+ if (!quota.observe(state.quotaSource, quotaTokens(state.accumulatedTokens))) return;
641
642
  log.info(`» ${state.params.label}: ${quota.gap()}`);
642
643
  state.stopRun.abort(new TokenQuotaExceededError(quota.gap()));
643
644
  }
@@ -85,6 +85,7 @@ import {
85
85
  runPostRunRetryLoop,
86
86
  shouldRunReflection,
87
87
  } from "#app/agents/postRun";
88
+ import { quotaTokens } from "#app/agents/quotaTokens";
88
89
  import { REVIEWER_AGENT_NAME } from "#app/agents/reviewer";
89
90
  import { ORCHESTRATOR_LABEL, SessionLabeler, withLabel } from "#app/agents/sessionLabeler";
90
91
  import {
@@ -458,7 +459,7 @@ export function newTurn(): TurnAccumulator {
458
459
  export function chargeTokenQuota(ctx: RunnerContext): void {
459
460
  const quota = ctx.tokenQuota;
460
461
  if (!quota || !ctx.currentTurn || ctx.tokenQuotaExceeded) return;
461
- if (!quota.observe(ctx.currentTurn.quotaSource, ctx.currentTurn.tokens)) return;
462
+ if (!quota.observe(ctx.currentTurn.quotaSource, quotaTokens(ctx.currentTurn.tokens))) return;
462
463
  ctx.tokenQuotaExceeded = true;
463
464
  log.info(`» ${ctx.label}: ${quota.gap()}`);
464
465
  ctx.abortRun?.(new TokenQuotaExceededError(quota.gap()));
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What one run's token usage counts against its token quota.
3
+ *
4
+ * Every turn re-reads the whole conversation from the prompt cache, so on a
5
+ * long-context run cache reads are almost all of the volume: a Remediate run on
6
+ * a 68 KB `main.tf` reached 8.5M tokens in four minutes of ordinary progress,
7
+ * ~95% of it cache reads. Counted 1:1, the quota stopped legitimate work long
8
+ * before it could stop a runaway. A cache read is billed at a tenth of an input
9
+ * token, so it is weighted the same here: a loop that keeps re-reading a large
10
+ * context still reaches the ceiling, and its cost — what the quota exists to
11
+ * bound — is measured as it is billed.
12
+ */
13
+ const CACHE_READ_WEIGHT = 0.1;
14
+
15
+ export interface TokenUsage {
16
+ input: number;
17
+ output: number;
18
+ cacheRead: number;
19
+ cacheWrite: number;
20
+ }
21
+
22
+ /** The pre-summed total the run's token quota observes. */
23
+ export function quotaTokens(t: TokenUsage): number {
24
+ return t.input + t.output + t.cacheWrite + t.cacheRead * CACHE_READ_WEIGHT;
25
+ }
@@ -3,4 +3,4 @@
3
3
  // verified fail-closed before any compliance verdict is produced; see crosswalk.ts.
4
4
  // Regenerate this file whenever the crosswalk tables in crosswalk.ts change intentionally.
5
5
  export const EXPECTED_CROSSWALK_DIGEST =
6
- "95afd7e402f0f1875fa0aac33aeee8b6b54f869ec65499fb727f515ee2101ee1";
6
+ "f583001997c4c69f84f189a14e69e4f6eb2c52bddc04ca7f404286b5280d1ff4";
@@ -939,6 +939,8 @@ const THEME_LOCKED: ReadonlyMap<string, string> = new Map([
939
939
  // does NOT also cite `encryption-at-rest`: whether the repository is encrypted is
940
940
  // not what the finding establishes.
941
941
  ["committed-state-file", "secrets-management"],
942
+ // a credential in the code is the same CIS 16.4 failure one step earlier than state in git
943
+ ["hardcoded-secret-argument", "secrets-management"],
942
944
  ]);
943
945
 
944
946
  /** the bare rule id, with any `<source>:` namespace stripped. */
package/src/mcp/git.ts CHANGED
@@ -936,11 +936,53 @@ const subcommandPattern = regex("^[a-z][a-z0-9-]*$");
936
936
 
937
937
  // exported so the security tests validate against the REAL schema rather than a
938
938
  // second copy of the pattern that could drift from it.
939
+ // a whole command line in `command` (`git checkout -b fix`), the way the shell
940
+ // tool takes one. Its subcommand is held to the same pattern once split.
941
+ const commandLinePattern = regex("^git\\s+[a-z][a-z0-9-]*(?:\\s.*)?$");
942
+
939
943
  export const Git = type({
940
- command: type(subcommandPattern).describe("Git command (e.g., 'status', 'log', 'diff')"),
944
+ command: type(subcommandPattern)
945
+ .or(type(commandLinePattern))
946
+ .describe("Git command (e.g., 'status', 'log', 'diff')")
947
+ .optional(),
941
948
  args: type.string.array().describe("Additional arguments for the git command").optional(),
942
949
  });
943
950
 
951
+ /** a command line split into words, honouring single and double quotes. */
952
+ function splitWords(line: string): string[] {
953
+ return [...line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
954
+ }
955
+
956
+ /**
957
+ * The subcommand and arguments of a call, however the model wrote it: the
958
+ * subcommand alone (the documented form), a whole `git …` command line, or the
959
+ * subcommand as the first of `args`. Models wrote the other two often enough
960
+ * to cost a turn per run, and each is unambiguous. The subcommand is checked
961
+ * against the same pattern the schema uses, so a flag (`-c`, `-C`,
962
+ * `--exec-path`) can no more arrive through `args[0]` or a command line than
963
+ * through `command`.
964
+ */
965
+ export function normalizeGitCall(params: {
966
+ command?: string | undefined;
967
+ args?: string[] | undefined;
968
+ }): {
969
+ command: string;
970
+ args: string[];
971
+ } {
972
+ let words: string[];
973
+ if (params.command === undefined) words = [...(params.args ?? [])];
974
+ else if (/^git\s/.test(params.command))
975
+ words = [...splitWords(params.command).slice(1), ...(params.args ?? [])];
976
+ else return { command: params.command, args: params.args ?? [] };
977
+ const [command, ...args] = words[0] === "git" ? words.slice(1) : words;
978
+ if (command === undefined || !/^[a-z][a-z0-9-]*$/.test(command)) {
979
+ throw new Error(
980
+ `command must be a Git subcommand such as 'status' or 'checkout' (was "${command ?? ""}")`,
981
+ );
982
+ }
983
+ return { command, args };
984
+ }
985
+
944
986
  export function GitTool(ctx: GitToolContext) {
945
987
  return tool({
946
988
  name: "git",
@@ -958,8 +1000,7 @@ export function GitTool(ctx: GitToolContext) {
958
1000
  "git pull is not available — use git_fetch then this tool with command 'merge'.",
959
1001
  parameters: Git,
960
1002
  execute: execute(async (params) => {
961
- const command = params.command;
962
- const args = params.args ?? [];
1003
+ const { command, args } = normalizeGitCall(params);
963
1004
 
964
1005
  // guard: {command:"status",args:["status"]} → `git status status`, where
965
1006
  // git silently treats args[0] as a pathspec. when nothing matches the
@@ -62,6 +62,10 @@ export interface LocalToolContext {
62
62
  // the local stdio MCP server too, so a CLI/IDE session benefits from a
63
63
  // committed `.infraweaver.fix-library.json`.
64
64
  | "fixMemory"
65
+ // the PR base a remediation branch is named for (see remediationBranchSuffix)
66
+ | "baseBranch"
67
+ // whether Refactor may propose changes inside `examples/` (refactorScope)
68
+ | "refactorExamples"
65
69
  >;
66
70
  toolState: ToolState;
67
71
  tmpdir: string;
@@ -72,4 +76,8 @@ export interface LocalToolContext {
72
76
  * bundle's PROVENANCE block — recording WHO produced the evidence, not
73
77
  * just what was found. */
74
78
  resolvedModel?: string | undefined;
79
+ /** the repository, for its default branch. The Action's full `ToolContext`
80
+ * carries it; a local session has none, so its remediation branches keep the
81
+ * plain `remediate/<id>` name. */
82
+ repo?: { data: { default_branch?: string | null } } | undefined;
75
83
  }
@@ -111,6 +111,9 @@ export function buildLocalContext(options: LocalMcpOptions): LocalToolContext {
111
111
  // blocked_destructive informationally with push_will_block: false.
112
112
  allowReplace: undefined,
113
113
  allowMajorUpgrade: undefined,
114
+ // no PR surface, so remediation branches keep their plain names
115
+ baseBranch: undefined,
116
+ refactorExamples: false,
114
117
  moduleCatalogue: options.moduleCatalogue,
115
118
  toolsEnabled: parseToolSelection(options.toolsEnabled),
116
119
  // no allowed_paths input on the local server, so the one writing tool
@@ -42,6 +42,7 @@ import {
42
42
  readFetchedGitModules,
43
43
  readFetchedRegistryModuleDirs,
44
44
  } from "#app/mcp/terraform/refactor/workspace";
45
+ import { isExamplePath, libraryRepoReason, reportOnly } from "#app/mcp/terraform/refactorScope";
45
46
  import { scanSessionOf } from "#app/mcp/terraform/scanSession";
46
47
  import { log } from "#app/utils/cli";
47
48
  import { pushInto } from "#app/utils/collect";
@@ -915,7 +916,8 @@ export function ModuleExtractionCandidatesTool(ctx: LocalToolContext) {
915
916
  cwd,
916
917
  ctx.payload.moduleCatalogue,
917
918
  ctx.toolState.orgModuleHcl,
918
- );
919
+ ).filter((c) => ctx.payload.refactorExamples || !isExamplePath(c.cluster.file));
920
+ const library = libraryRepoReason(cwd);
919
921
  const matched = candidates.filter((c) => c.candidates.length > 0);
920
922
  // Record every candidate's reason, keyed by source, for create_pull_request:
921
923
  // when a refactor PR's diff adopts one of these modules and the agent left
@@ -943,6 +945,7 @@ export function ModuleExtractionCandidatesTool(ctx: LocalToolContext) {
943
945
  : ""),
944
946
  );
945
947
  return toolOk({
948
+ ...reportOnly(library),
946
949
  cluster_count: candidates.length,
947
950
  matched_count: matched.length,
948
951
  candidates,
package/src/mcp/shell.ts CHANGED
@@ -17,7 +17,7 @@ import { log } from "#app/utils/log";
17
17
 
18
18
  export const ShellParams = type({
19
19
  command: "string",
20
- description: "string",
20
+ "description?": "string",
21
21
  "timeout?": type.number.describe(
22
22
  "Timeout in MILLISECONDS (not seconds). Default 30000 (30s), max 120000 (2m). e.g. timeout: 180000 for 3 minutes; timeout: 180 means 180ms and will kill the process almost immediately.",
23
23
  ),
@@ -455,7 +455,7 @@ export function ShellTool(ctx: ToolContext) {
455
455
  return tool({
456
456
  name: "shell",
457
457
  timeoutMs: 120_000,
458
- description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
458
+ description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets. NOT for git: any command running \`git\` is refused here — use the git tool (and push_branch / git_fetch).
459
459
 
460
460
  Example: \`shell({ command: "pnpm test", description: "run the test suite" })\`.
461
461
 
@@ -38,9 +38,28 @@ export function isRemediationBranch(branch: string): boolean {
38
38
  }
39
39
 
40
40
  /** the `<group-id>` of a `remediate/<group-id>` branch (the scan group id that
41
- * keys the fix), or null for a generation branch / non-remediation branch. */
41
+ * keys the fix), or null for a generation branch / non-remediation branch. A
42
+ * `--<base>` suffix (see [[remediationBranchSuffix]]) is not part of the id. */
42
43
  export function groupIdFromBranch(branch: string): string | null {
43
- return branch.match(/^remediate\/(.+)$/)?.[1] ?? null;
44
+ return branch.match(/^remediate\/(.+?)(?:--.+)?$/)?.[1] ?? null;
45
+ }
46
+
47
+ /**
48
+ * What a remediation branch name carries after its group id: nothing when the
49
+ * PR targets the repository default branch, `--<base>` otherwise.
50
+ *
51
+ * The group id is a hash of the file (or rule) alone, so the same fix aimed at
52
+ * two base branches — or at two repos whose roots are both `main.tf` — named
53
+ * the same branch, and a run that found it taken improvised another name the
54
+ * refresh and concurrency tools could not see. A default-branch fix keeps the
55
+ * plain name, so the PRs already open under it are still found and refreshed.
56
+ */
57
+ export function remediationBranchSuffix(
58
+ base: string | undefined,
59
+ defaultBranch: string | undefined,
60
+ ): string {
61
+ if (!base || !defaultBranch || base === defaultBranch) return "";
62
+ return `--${base.replace(/[^A-Za-z0-9._-]+/g, "-")}`;
44
63
  }
45
64
 
46
65
  /** core matcher: a NON-EMPTY identity string (a GitHub login or a git author
@@ -1,4 +1,5 @@
1
1
  import type { LocalToolContext } from "#app/mcp/localContext";
2
+ import { remediationBranchSuffix } from "#app/mcp/staleFix";
2
3
  import {
3
4
  capSeededConcerns,
4
5
  type PrioritySignals,
@@ -65,6 +66,18 @@ export function annotateConcerns(
65
66
  );
66
67
  }
67
68
 
69
+ /** The branch this run's PRs target, as `create_pull_request` resolves it: the
70
+ * `base_branch` input, else the branch the run started on, else the default. */
71
+ function remediationBaseOf(ctx: LocalToolContext): string | undefined {
72
+ const head = ctx.toolState.initialHead;
73
+ return (
74
+ ctx.payload.baseBranch?.trim() ||
75
+ (head?.kind === "branch" ? head.name : undefined) ||
76
+ ctx.repo?.data.default_branch?.trim() ||
77
+ undefined
78
+ );
79
+ }
80
+
68
81
  /**
69
82
  * The shared concerns→result pipeline behind `terraform_scan` and `read_findings`:
70
83
  * capture the line-pinned + line-independent baseline (so verify's regression diff
@@ -141,10 +154,17 @@ export function buildConcernResult(
141
154
  active,
142
155
  opts.autonomyThreshold,
143
156
  );
144
- const groups = opts.priority
145
- ? prioritizeGroups(annotatedGroups, active, opts.priority)
146
- : annotatedGroups;
147
- const batchPlan = planBatches(groups);
157
+ const suffix = remediationBranchSuffix(
158
+ remediationBaseOf(ctx),
159
+ ctx.repo?.data.default_branch?.trim() || undefined,
160
+ );
161
+ const groups = (
162
+ opts.priority ? prioritizeGroups(annotatedGroups, active, opts.priority) : annotatedGroups
163
+ ).map((g) => ({ ...g, branch: `remediate/${g.id}${suffix}` }));
164
+ const planned = planBatches(groups);
165
+ const batchPlan = planned.batch_branch
166
+ ? { ...planned, batch_branch: `${planned.batch_branch}${suffix}` }
167
+ : planned;
148
168
 
149
169
  const bySeverity: Record<string, number> = {};
150
170
  for (const c of active) bySeverity[c.severity] = (bySeverity[c.severity] ?? 0) + 1;