infraweaver 0.3.12 → 0.3.13

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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/opencode.d.ts +6 -0
  3. package/dist/agents/quotaTokens.d.ts +6 -0
  4. package/dist/cli.mjs +554 -199
  5. package/dist/index.js +531 -176
  6. package/dist/internal.js +4 -4
  7. package/dist/mcp/git.d.ts +6 -4
  8. package/dist/mcp/localContext.d.ts +7 -0
  9. package/dist/mcp/reviewCommentLimit.d.ts +55 -5
  10. package/dist/mcp/reviewMarkers.d.ts +5 -0
  11. package/dist/mcp/staleFix.d.ts +36 -1
  12. package/dist/mcp/terraform/concernResult.d.ts +9 -0
  13. package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
  14. package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
  15. package/dist/mcp/terraform/refactorScope.d.ts +14 -2
  16. package/dist/mcp/terraform/ruleContext.d.ts +3 -0
  17. package/dist/mcp/terraform/tree.d.ts +5 -0
  18. package/dist/mcp/terraform/types.d.ts +2 -1
  19. package/dist/mcp/terraform/verifySites.d.ts +11 -3
  20. package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
  21. package/package.json +2 -2
  22. package/src/agents/opencode.ts +8 -1
  23. package/src/agents/quotaTokens.ts +7 -1
  24. package/src/mcp/git.ts +64 -12
  25. package/src/mcp/localContext.ts +6 -1
  26. package/src/mcp/moduleExtraction.ts +5 -4
  27. package/src/mcp/review.ts +52 -3
  28. package/src/mcp/reviewCommentLimit.ts +169 -26
  29. package/src/mcp/reviewMarkers.ts +11 -0
  30. package/src/mcp/reviewProvenance.ts +1 -1
  31. package/src/mcp/staleFix.ts +73 -2
  32. package/src/mcp/terraform/concernResult.ts +17 -10
  33. package/src/mcp/terraform/deltaSummary.ts +4 -0
  34. package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
  35. package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
  36. package/src/mcp/terraform/nativeScan.ts +4 -0
  37. package/src/mcp/terraform/refactor/equivalence.ts +42 -9
  38. package/src/mcp/terraform/refactorScope.ts +90 -21
  39. package/src/mcp/terraform/ruleContext.ts +4 -0
  40. package/src/mcp/terraform/scanDelta.ts +67 -26
  41. package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
  42. package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
  43. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
  44. package/src/mcp/terraform/tools/readFindings.ts +2 -0
  45. package/src/mcp/terraform/tools/scan.ts +3 -0
  46. package/src/mcp/terraform/tree.ts +8 -0
  47. package/src/mcp/terraform/types.ts +2 -1
  48. package/src/mcp/terraform/verifySites.ts +23 -5
  49. package/src/modes/refactor.ts +1 -1
  50. package/src/modes/remediate-and-refactor.ts +1 -1
  51. package/src/modes/remediate.ts +1 -1
  52. package/src/phases/runAgentWithWatchdogs.ts +3 -2
package/dist/internal.js CHANGED
@@ -110,7 +110,7 @@ function isValidTimeString(input) {
110
110
  return parseTimeString(input) !== null;
111
111
  }
112
112
 
113
- // node_modules/.pnpm/@infraweaver-io+models@0.1.6/node_modules/@infraweaver-io/models/dist/catalog.js
113
+ // node_modules/.pnpm/@infraweaver-io+models@0.1.7/node_modules/@infraweaver-io/models/dist/catalog.js
114
114
  function provider(config) {
115
115
  return config;
116
116
  }
@@ -1781,7 +1781,7 @@ 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\`.
1784
+ **A candidate marked \`report_only\` is an observation, not work.** It sits in the code of a module other configurations call (\`report_only_reason\` says why), and restructuring that 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
1785
 
1786
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.
1787
1787
 
@@ -1914,7 +1914,7 @@ function remediateMode(t) {
1914
1914
 
1915
1915
  4. **for the chosen group**:
1916
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.
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.
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, or the branch of the PR already open for this group) \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
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.
1919
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.
1920
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.
@@ -1975,7 +1975,7 @@ This mode composes the **Remediate** and **Refactor** verbs into a single run. I
1975
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.
1976
1976
 
1977
1977
  3. **REFACTOR (phase 2) \u2014 modularise on top of the committed fix**: follow the **Refactor** mode's flow:
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\`).
1978
+ - \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` \u2192 pick one behaviour-preserving refactor; a candidate marked \`report_only\` (it sits in the code of 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\`).
1979
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")}\`).
1980
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.
1981
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.
package/dist/mcp/git.d.ts CHANGED
@@ -51,10 +51,12 @@ export declare const Git: import("arktype/internal/variants/object.ts").ObjectTy
51
51
  * The subcommand and arguments of a call, however the model wrote it: the
52
52
  * subcommand alone (the documented form), a whole `git …` command line, or the
53
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`.
54
+ * to cost a turn per run, and each is unambiguous. Leading `git` words are
55
+ * dropped, so `{ command: "git", args: ["status"] }` is not `git git status`.
56
+ * The subcommand is checked against the same pattern the schema uses — in
57
+ * every form, the documented one included, so the check does not rest on the
58
+ * schema alone — and a flag (`-c`, `-C`, `--exec-path`) can no more arrive
59
+ * through `args[0]` or a command line than through `command`.
58
60
  */
59
61
  export declare function normalizeGitCall(params: {
60
62
  command?: string | undefined;
@@ -1,5 +1,6 @@
1
1
  import type { ToolState } from "../toolState.js";
2
2
  import type { ResolvedPayload } from "../utils/config/payload.js";
3
+ import type { OctokitWithPlugins } from "../utils/github/github.js";
3
4
  /**
4
5
  * The cwd-scoped, GitHub-free subset of `ToolContext` that the read-only
5
6
  * Terraform tools depend on. Two providers exist:
@@ -27,8 +28,14 @@ export interface LocalToolContext {
27
28
  * carries it; a local session has none, so its remediation branches keep the
28
29
  * plain `remediate/<id>` name. */
29
30
  repo?: {
31
+ owner?: string;
32
+ name?: string;
30
33
  data: {
31
34
  default_branch?: string | null;
32
35
  };
33
36
  } | undefined;
37
+ /** the Action's GitHub client, read only to find the remediation PRs already
38
+ * open, so a group that has one keeps that PR's branch. A local session has
39
+ * none and names its branches from the group and base alone. */
40
+ octokit?: OctokitWithPlugins | undefined;
34
41
  }
@@ -1,3 +1,29 @@
1
+ /**
2
+ * How many inline comments one review posts.
3
+ *
4
+ * A review of forty inline comments is read by no one, and every one of them is
5
+ * a thread the author must resolve. Past the limit the most important comments
6
+ * still post inline — ranked by the scanner severity of the finding a comment
7
+ * answers, or else by its own 🚨/⚠️/ℹ️ marker (the marker is optional, so an
8
+ * unmarked comment ranks level with ⚠️, not last), a proven (one-click
9
+ * committable) fix ahead of an equal one without, otherwise in the order the
10
+ * reviewer wrote them — and the rest are listed in the review body in full, so
11
+ * nothing the review found is lost, only folded. The gate is not involved: it
12
+ * judges the scan delta, never the comments.
13
+ *
14
+ * A folded comment opens no thread, so the approval check that counts open
15
+ * threads cannot see it. A folded scanner finding keeps its hidden marker in
16
+ * the review body instead, and a later approval is refused while the head
17
+ * still introduces it (`foldedFindingsStillIntroduced`).
18
+ *
19
+ * The limit is a repo setting (`review_comment_limit`), read from the base
20
+ * branch on a pull request: lowering it moves findings out of the diff view,
21
+ * which is a way to make them easier to miss, so a PR does not get to set it for
22
+ * itself.
23
+ */
24
+ import type { Dismissal } from "./reviewProvenance.js";
25
+ import { type DeltaFinding, type GateDelta } from "./terraform/scanDelta.js";
26
+ import type { Severity } from "./terraform/types.js";
1
27
  interface LimitableComment {
2
28
  path: string;
3
29
  line?: number | undefined;
@@ -5,13 +31,37 @@ interface LimitableComment {
5
31
  }
6
32
  /**
7
33
  * Split `comments` into those posted inline and those folded into the body.
8
- * `limit` 0 or undefined means no limit. Both halves keep the input order, so
9
- * the posted review reads in the order the reviewer wrote it.
34
+ * `limit` 0 or undefined means no limit. `severityOf` names the severity of the
35
+ * finding a comment answers, when it answers one. Both halves keep the input
36
+ * order, so the posted review reads in the order the reviewer wrote it.
10
37
  */
11
- export declare function limitInlineComments<T extends LimitableComment>(comments: readonly T[], limit: number | undefined): {
38
+ export declare function limitInlineComments<T extends LimitableComment>(comments: readonly T[], limit: number | undefined, severityOf?: (c: T) => Severity | undefined): {
12
39
  kept: T[];
13
40
  overflow: T[];
14
41
  };
15
- /** The review-body note listing the comments the limit folded, or "" for none. */
16
- export declare function formatOverflowNote(overflow: readonly LimitableComment[], limit: number): string;
42
+ /**
43
+ * The review-body note carrying the comments the limit folded, or "" for none.
44
+ * Each is kept whole while the note stays within its share of GitHub's body
45
+ * limit; past that, by place and first line (and its finding marker), and the
46
+ * note says so.
47
+ */
48
+ export declare function formatOverflowNote<T extends LimitableComment>(overflow: readonly T[], limit: number, linkOf?: (c: T) => string | undefined): string;
49
+ /**
50
+ * The introduced findings an earlier review of this PR folded into its body —
51
+ * so they never became threads — that this head still introduces and this
52
+ * review does not dismiss. An approval past one would pass a finding the
53
+ * open-thread check cannot see. Markers count only in the engine's own reviews;
54
+ * one pasted elsewhere can only add a finding to this list, never remove one.
55
+ */
56
+ export declare function foldedFindingsStillIntroduced(input: {
57
+ reviews: readonly {
58
+ user?: {
59
+ login?: string | null;
60
+ type?: string | null;
61
+ } | null;
62
+ body?: string | null;
63
+ }[];
64
+ delta: GateDelta;
65
+ dismissed: readonly Dismissal[];
66
+ }): DeltaFinding[];
17
67
  export {};
@@ -15,6 +15,11 @@ export type CommentMarker = {
15
15
  };
16
16
  export declare function renderCommentMarker(marker: CommentMarker): string;
17
17
  export declare function parseCommentMarker(body: string | null | undefined): CommentMarker | null;
18
+ /**
19
+ * The key of every finding marker in `body`. A review BODY carries one per
20
+ * comment the comment limit folded into it, where a comment carries one.
21
+ */
22
+ export declare function findingKeysIn(body: string): string[];
18
23
  /** The text of a comment's `suggestion` block, without its closing newline. */
19
24
  export declare function suggestionText(body: string): string | undefined;
20
25
  /**
@@ -1,4 +1,5 @@
1
1
  import type { GitHubToolContext } from "./capabilityContext.js";
2
+ import type { OctokitWithPlugins } from "../utils/github/github.js";
2
3
  /** true when `branch` is a Infraweaver remediation/generation branch. */
3
4
  export declare function isRemediationBranch(branch: string): boolean;
4
5
  /** the `<group-id>` of a `remediate/<group-id>` branch (the scan group id that
@@ -7,7 +8,8 @@ export declare function isRemediationBranch(branch: string): boolean;
7
8
  export declare function groupIdFromBranch(branch: string): string | null;
8
9
  /**
9
10
  * What a remediation branch name carries after its group id: nothing when the
10
- * PR targets the repository default branch, `--<base>` otherwise.
11
+ * PR targets the repository default branch, `--<base>` otherwise — slugged for
12
+ * a ref, with a hash of the raw name when the slug changed it.
11
13
  *
12
14
  * The group id is a hash of the file (or rule) alone, so the same fix aimed at
13
15
  * two base branches — or at two repos whose roots are both `main.tf` — named
@@ -16,6 +18,39 @@ export declare function groupIdFromBranch(branch: string): string | null;
16
18
  * plain name, so the PRs already open under it are still found and refreshed.
17
19
  */
18
20
  export declare function remediationBranchSuffix(base: string | undefined, defaultBranch: string | undefined): string;
21
+ /** an open remediation PR, as far as naming a group's branch needs it. */
22
+ export interface OpenRemediationPr {
23
+ head: string;
24
+ base: string;
25
+ }
26
+ /**
27
+ * The branch a remediation group's fix is pushed to: the head of the PR already
28
+ * open for this group into this exact base, when there is one, else
29
+ * `remediate/<id>` plus [[remediationBranchSuffix]].
30
+ *
31
+ * The open PR wins because its name may predate the current naming — opened
32
+ * before the base suffix existed, or under an unhashed suffix — and Remediate
33
+ * keys idempotency on the name, so a newly computed one opened a duplicate PR.
34
+ * A head that another open PR also proposes into a different base is never
35
+ * reused: pushing to it would rewrite that PR too.
36
+ */
37
+ export declare function remediationBranchFor(id: string, base: string | undefined, defaultBranch: string | undefined, open: readonly OpenRemediationPr[] | undefined): string;
38
+ /**
39
+ * This repository's open `remediate/*` PRs, for [[remediationBranchFor]]. Empty
40
+ * without a GitHub client (a local session) or when the API call fails: the
41
+ * group then gets the computed name, as it did before open PRs were consulted.
42
+ * Only Remediate pushes to a group's branch, so no other mode pays for the call.
43
+ */
44
+ export declare function listOpenRemediationPrs(ctx: {
45
+ octokit?: OctokitWithPlugins | undefined;
46
+ repo?: {
47
+ owner?: string;
48
+ name?: string;
49
+ } | undefined;
50
+ toolState?: {
51
+ selectedMode?: string | undefined;
52
+ } | undefined;
53
+ }): Promise<OpenRemediationPr[]>;
19
54
  /** true when a commit-author LOGIN is Infraweaver's bot (so a commit by it is NOT
20
55
  * a human edit). A null/absent login is treated as non-human here for backward
21
56
  * compatibility of this login-only helper — but prefer {@link isBotCommit}, which
@@ -1,4 +1,5 @@
1
1
  import type { LocalToolContext } from "../localContext.js";
2
+ import { type OpenRemediationPr } from "../staleFix.js";
2
3
  import { type PrioritySignals, type SeedCapResult, type SeedCaps } from "./coverage.js";
3
4
  import { annotateGroups, planBatches } from "./decisions.js";
4
5
  import { type Concern, type Severity } from "./types.js";
@@ -59,4 +60,12 @@ export declare function buildConcernResult(ctx: LocalToolContext, rawConcerns: C
59
60
  * the full raw set — so a capped concern the re-scan still sees can never
60
61
  * read as a regression. */
61
62
  seedCaps?: SeedCaps;
63
+ /** The repository's open remediation PRs (see `listOpenRemediationPrs`), so
64
+ * a group that already has one keeps its branch. */
65
+ openRemediationPrs?: readonly OpenRemediationPr[];
66
+ /** The concerns were scanned off the tree in `cwd` as it is now, so their
67
+ * lines name today's resources (see `recordConcernSites`). Only
68
+ * `terraform_scan` sets it: a findings file or external report may
69
+ * describe another commit. */
70
+ linesMatchTree?: boolean;
62
71
  }): ConcernResult;
@@ -14,6 +14,9 @@ export interface DeltaVerdict {
14
14
  gating: number;
15
15
  /** introduced findings on a required control, any severity */
16
16
  required: number;
17
+ /** introduced findings that fail either half, each counted once: a high
18
+ * finding on a required control is in both `gating` and `required`. */
19
+ blocking: number;
17
20
  }
18
21
  /** The gate's verdict on a delta: the same two halves `findings-gate` applies. */
19
22
  export declare function deltaVerdict(delta: GateDelta, requiredControls: string[]): DeltaVerdict;
@@ -12,15 +12,21 @@
12
12
  * WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
13
13
  * `provider`, `module` or `locals` block, at any depth, so
14
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.
15
+ * are reached; the `default` of a `variable` whose name is a secret's; and
16
+ * every assignment in a `.tfvars` file, which is where a value kept out of the
17
+ * `.tf` source usually lands instead. Not read: comments, heredoc bodies, a
18
+ * name/value pair split across two keys (`[{ name = "PASSWORD", value = "…" }]`),
19
+ * and a value that starts on a later line than its name.
19
20
  *
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.
21
+ * WHAT IS NOT A FINDING. A reference, a function call, an interpolation or a
22
+ * template directive, an empty string, `true` / `false` / `null`, a number, or
23
+ * a placeholder made only of `*` / `x` / `<…>`. Nor is a literal that only POINTS
24
+ * at a secret — an ARN, a GCP `projects/…/secrets/…` name, a Key Vault secret
25
+ * URI or an SSM parameter path — since that is the fix, not the fault. Neither
26
+ * is an argument whose name only mentions a secret (`password_length`,
27
+ * `secret_name`, `token_validity_units`, `creation_token`): the last word of the
28
+ * name decides, and a name that is `secret` and nothing else names a Secret
29
+ * Manager secret (`secret_key_ref { secret = "db-password" }`), not its value.
24
30
  *
25
31
  * Arguments the provider can take write-only (`aws_db_instance.password`) may
26
32
  * also be reported by `secret-persisted-to-state`, which says something else:
@@ -45,5 +51,13 @@ export interface HardcodedSecret {
45
51
  * parser is shared by every scanner and budgeted (see nativeScan.perf.test).
46
52
  */
47
53
  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[];
54
+ /**
55
+ * Every secret-named literal in a `.tfvars` file. A tfvars file is nothing but
56
+ * assignments, so every one is read, at any depth, under the file's own name.
57
+ */
58
+ export declare function findTfvarsSecrets(hcl: string, path: string): HardcodedSecret[];
59
+ /**
60
+ * Scan an already-read tree into native concerns. Pure; no I/O of its own.
61
+ * `tfvarsFiles` are the `.tfvars` inputs, read as assignments rather than blocks.
62
+ */
63
+ export declare function scanHardcodedSecrets(files: readonly SourceFile[], tfvarsFiles?: readonly SourceFile[]): Concern[];
@@ -1,11 +1,23 @@
1
- /** true for a path inside an `examples/` (or `example/`) directory, at any depth. */
1
+ /**
2
+ * true for a path inside an `examples/` directory at any depth, or an `example/`
3
+ * one at the repository root. The registry's convention is `examples/`; the
4
+ * singular is accepted only where that convention puts it, because a module
5
+ * NAMED `example` (`modules/example/`) is code someone calls.
6
+ */
2
7
  export declare function isExamplePath(path: string): boolean;
3
8
  /**
4
9
  * Why the repository at `cwd` reads as a reusable module rather than a
5
10
  * deployment, or null when it does not.
6
11
  */
7
12
  export declare function libraryRepoReason(cwd: string): string | null;
8
- /** what a detector tool adds to its result when the repository is a library. */
13
+ /**
14
+ * For a library repository, why a candidate in `file` (repo-relative) is
15
+ * reported rather than restructured — or null when the file belongs to a
16
+ * directory, or sits under one, that deploys on its own. Outside a library,
17
+ * always null.
18
+ */
19
+ export declare function libraryScope(cwd: string): (file: string) => string | null;
20
+ /** what a detector tool adds to a candidate in a library's own code. */
9
21
  export declare function reportOnly(reason: string | null): {
10
22
  report_only?: true;
11
23
  report_only_reason?: string;
@@ -44,6 +44,8 @@ export interface RuleContext {
44
44
  readonly files: readonly SourceFile[];
45
45
  /** the `.tofu` half. Only the dialect rule needs it; empty on a repo with none. */
46
46
  readonly tofuFiles: readonly SourceFile[];
47
+ /** the `.tfvars` inputs. Only the hardcoded-secret rule reads them; empty on a repo with none. */
48
+ readonly tfvarsFiles: readonly SourceFile[];
47
49
  /** non-null when the walk was bounded before it finished — see [[tree]]. */
48
50
  readonly truncated: TreeTruncation;
49
51
  /** how many times a literal must repeat in one directory before it is reported. */
@@ -75,6 +77,7 @@ export interface RuleContextInput {
75
77
  cwd: string;
76
78
  files: readonly SourceFile[];
77
79
  tofuFiles?: readonly SourceFile[];
80
+ tfvarsFiles?: readonly SourceFile[];
78
81
  truncated?: TreeTruncation;
79
82
  repeatedLiteralThreshold: number;
80
83
  }
@@ -102,6 +102,11 @@ export declare function readTfTree(cwd: string, limits?: TreeReadLimits): Source
102
102
  * one is a fact about the PAIR.
103
103
  */
104
104
  export declare function readTofuTree(cwd: string, limits?: TreeReadLimits): SourceTree;
105
+ /**
106
+ * The `.tfvars` inputs (`.auto.tfvars` included). Not configuration, so no rule
107
+ * reads them as blocks; the hardcoded-secret rule reads their assignments.
108
+ */
109
+ export declare function readTfvarsTree(cwd: string, limits?: TreeReadLimits): SourceTree;
105
110
  /** how a declined-for-size file reads in a coverage report. Names the files,
106
111
  * because unlike a truncated walk this gap knows exactly what went unread. */
107
112
  export declare function describeOversize(paths: readonly string[]): string;
@@ -243,7 +243,8 @@ export interface ConcernGroup {
243
243
  * the file (by-file grouping) or the rule (by-rule grouping). */
244
244
  id: string;
245
245
  /** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
246
- * PR targets a branch other than the repository default. */
246
+ * PR targets a branch other than the repository default — or the head of the
247
+ * remediation PR already open for this group into the same base. */
247
248
  branch?: string;
248
249
  /** the group's primary file (by-file) or a human label like "3 files"
249
250
  * (by-rule); `files` carries the full list for by-rule groups. */
@@ -8,7 +8,8 @@
8
8
  * a fix that moves the defect onto a new resource would look resolved — so a
9
9
  * finding counts as resolved at its own site only when ALL of these hold:
10
10
  *
11
- * - it was placed on a resource when it was reported;
11
+ * - it was placed on a resource when it was reported, by a scan of the tree
12
+ * as it then stood, and no later scan placed the same id elsewhere;
12
13
  * - that resource is still declared in the file (a renamed or deleted one
13
14
  * proves nothing);
14
15
  * - the rule no longer fires on it;
@@ -24,8 +25,15 @@ import { type Concern } from "./types.js";
24
25
  export declare function fileReader(cwd: string): (file: string) => string | null;
25
26
  /** The resource a concern sits in, read from the file as it is now. */
26
27
  export declare function siteOf(c: Concern, read: (file: string) => string | null): string | null;
27
- /** Remember where each reported concern sat, and the first sites per key. */
28
- export declare function recordConcernSites(session: ScanSession, concerns: Concern[], cwd: string): void;
28
+ /**
29
+ * Remember where each reported concern sat, and the first sites per key.
30
+ *
31
+ * `linesMatchTree` says the concerns were scanned off the tree in `cwd` as it
32
+ * is now. Without it (a findings file or external report, which may describe
33
+ * another commit) their lines are not trusted to name today's resources: their
34
+ * ids get no site and their keys no first sites.
35
+ */
36
+ export declare function recordConcernSites(session: ScanSession, concerns: Concern[], cwd: string, linesMatchTree: boolean): void;
29
37
  export interface SiteRequest {
30
38
  id: string;
31
39
  /** the (scanner, rule, file) key */
@@ -44,10 +44,11 @@ export declare class TokenQuotaExceededError extends Error {
44
44
  }
45
45
  /** The workflow input that overrides {@link DEFAULT_RUN_TOKEN_QUOTA}. */
46
46
  export declare const RUN_TOKEN_QUOTA_INPUT = "max_run_tokens";
47
- /** The four token components a run is charged for. Cache reads are cheap per
48
- * token but they are still work performed on the model's behalf, and a
49
- * runaway loop is visible mostly in cache reads — excluding them would leave
50
- * the loudest signal of the very failure this bounds outside the count. */
47
+ /** The four token components a run is charged for. Cache reads still count —
48
+ * a runaway loop is visible mostly in cache reads, and excluding them would
49
+ * leave the loudest signal of the very failure this bounds outside the count —
50
+ * but at {@link CACHE_READ_WEIGHT}, their price, so a long healthy run that
51
+ * re-reads a large cached prompt every turn is not stopped for it. */
51
52
  interface TokenComponents {
52
53
  input?: number | undefined;
53
54
  output?: number | undefined;
@@ -55,9 +56,9 @@ interface TokenComponents {
55
56
  cacheWrite?: number | undefined;
56
57
  }
57
58
  export interface RunTokenQuota {
58
- /** The ceiling, in billable tokens. */
59
+ /** The ceiling, in quota tokens (a cache read counts as a tenth). */
59
60
  readonly limit: number;
60
- /** Billable tokens charged so far across every source. */
61
+ /** Quota tokens charged so far across every source. */
61
62
  used(): number;
62
63
  /**
63
64
  * Report a source's running TOTAL (not a delta) and return whether the quota
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infraweaver",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
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",
@@ -103,7 +103,7 @@
103
103
  "@infraweaver-io/ci": "^0.1.8",
104
104
  "@infraweaver-io/core": "^0.1.5",
105
105
  "@infraweaver-io/crosswalk": "^1.2.0",
106
- "@infraweaver-io/models": "^0.1.6",
106
+ "@infraweaver-io/models": "^0.1.7",
107
107
  "@infraweaver-io/policy-spec": "^1.2.0",
108
108
  "@infraweaver-io/report-contract": "^0.1.5",
109
109
  "@infraweaver-io/sandbox": "^0.1.8",
@@ -417,6 +417,12 @@ export interface TurnAccumulator {
417
417
  tokens: {
418
418
  input: number;
419
419
  output: number;
420
+ /**
421
+ * opencode reports reasoning apart from `output` (which excludes it). Kept
422
+ * separate so it reaches the token quota without changing the reported
423
+ * output count; cost already prices it, via opencode's per-step `cost`.
424
+ */
425
+ reasoning: number;
420
426
  cacheRead: number;
421
427
  cacheWrite: number;
422
428
  };
@@ -439,7 +445,7 @@ export function newTurn(): TurnAccumulator {
439
445
  turnCounter += 1;
440
446
  return {
441
447
  finalText: "",
442
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
448
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
443
449
  costUsd: 0,
444
450
  sessionError: null,
445
451
  lastToolError: null,
@@ -739,6 +745,7 @@ async function onPartUpdated(ctx: RunnerContext, part: Part): Promise<void> {
739
745
  if (t) {
740
746
  ctx.currentTurn.tokens.input += t.input || 0;
741
747
  ctx.currentTurn.tokens.output += t.output || 0;
748
+ ctx.currentTurn.tokens.reasoning += t.reasoning || 0;
742
749
  ctx.currentTurn.tokens.cacheRead += t.cache?.read || 0;
743
750
  ctx.currentTurn.tokens.cacheWrite += t.cache?.write || 0;
744
751
  }
@@ -15,11 +15,17 @@ const CACHE_READ_WEIGHT = 0.1;
15
15
  export interface TokenUsage {
16
16
  input: number;
17
17
  output: number;
18
+ /**
19
+ * Reasoning tokens a harness reports apart from `output`. opencode does (its
20
+ * `output` excludes them, its cost prices them as output); Anthropic's
21
+ * `output_tokens` already include thinking, so the Claude harness has none.
22
+ */
23
+ reasoning?: number;
18
24
  cacheRead: number;
19
25
  cacheWrite: number;
20
26
  }
21
27
 
22
28
  /** The pre-summed total the run's token quota observes. */
23
29
  export function quotaTokens(t: TokenUsage): number {
24
- return t.input + t.output + t.cacheWrite + t.cacheRead * CACHE_READ_WEIGHT;
30
+ return t.input + t.output + (t.reasoning ?? 0) + t.cacheWrite + t.cacheRead * CACHE_READ_WEIGHT;
25
31
  }
package/src/mcp/git.ts CHANGED
@@ -948,19 +948,68 @@ export const Git = type({
948
948
  args: type.string.array().describe("Additional arguments for the git command").optional(),
949
949
  });
950
950
 
951
- /** a command line split into words, honouring single and double quotes. */
951
+ /**
952
+ * A command line split into words the way a POSIX shell splits it, with no
953
+ * expansion: a quote may open mid-word and is removed (`--message="a b"` is
954
+ * the one word `--message=a b`), single quotes are literal, a backslash
955
+ * escapes the next character outside them (inside double quotes only `"`,
956
+ * `\`, `$` and `` ` ``), and adjacent parts join into one word. What a shell
957
+ * would read differently — an unterminated quote, a trailing backslash, an
958
+ * unquoted newline (a second command) — throws instead of being guessed at.
959
+ */
952
960
  function splitWords(line: string): string[] {
953
- return [...line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
961
+ const words: string[] = [];
962
+ let word = "";
963
+ let inWord = false;
964
+ let quote: "'" | '"' | undefined;
965
+ for (let i = 0; i < line.length; i++) {
966
+ const ch = line.charAt(i);
967
+ if (quote === "'") {
968
+ if (ch === "'") quote = undefined;
969
+ else word += ch;
970
+ } else if (quote === '"') {
971
+ if (ch === '"') quote = undefined;
972
+ else if (ch === "\\" && i + 1 < line.length && '"\\$`\n'.includes(line.charAt(i + 1))) {
973
+ i += 1;
974
+ if (line.charAt(i) !== "\n") word += line.charAt(i);
975
+ } else word += ch;
976
+ } else if (ch === " " || ch === "\t") {
977
+ if (inWord) words.push(word);
978
+ word = "";
979
+ inWord = false;
980
+ } else if (ch === "\n") {
981
+ throw new Error(
982
+ "command holds an unquoted newline — pass one git command per call, and quote a multi-line message",
983
+ );
984
+ } else if (ch === "\\") {
985
+ if (i + 1 >= line.length) throw new Error("command ends in a backslash that escapes nothing");
986
+ i += 1;
987
+ if (line.charAt(i) === "\n") continue;
988
+ word += line.charAt(i);
989
+ inWord = true;
990
+ } else if (ch === "'" || ch === '"') {
991
+ quote = ch;
992
+ inWord = true;
993
+ } else {
994
+ word += ch;
995
+ inWord = true;
996
+ }
997
+ }
998
+ if (quote) throw new Error(`command has an unterminated ${quote} quote`);
999
+ if (inWord) words.push(word);
1000
+ return words;
954
1001
  }
955
1002
 
956
1003
  /**
957
1004
  * The subcommand and arguments of a call, however the model wrote it: the
958
1005
  * subcommand alone (the documented form), a whole `git …` command line, or the
959
1006
  * 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`.
1007
+ * to cost a turn per run, and each is unambiguous. Leading `git` words are
1008
+ * dropped, so `{ command: "git", args: ["status"] }` is not `git git status`.
1009
+ * The subcommand is checked against the same pattern the schema uses — in
1010
+ * every form, the documented one included, so the check does not rest on the
1011
+ * schema alone — and a flag (`-c`, `-C`, `--exec-path`) can no more arrive
1012
+ * through `args[0]` or a command line than through `command`.
964
1013
  */
965
1014
  export function normalizeGitCall(params: {
966
1015
  command?: string | undefined;
@@ -969,12 +1018,15 @@ export function normalizeGitCall(params: {
969
1018
  command: string;
970
1019
  args: string[];
971
1020
  } {
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;
1021
+ const words =
1022
+ params.command === undefined
1023
+ ? []
1024
+ : /^git\s/.test(params.command)
1025
+ ? splitWords(params.command)
1026
+ : [params.command];
1027
+ words.push(...(params.args ?? []));
1028
+ while (words[0] === "git") words.shift();
1029
+ const [command, ...args] = words;
978
1030
  if (command === undefined || !/^[a-z][a-z0-9-]*$/.test(command)) {
979
1031
  throw new Error(
980
1032
  `command must be a Git subcommand such as 'status' or 'checkout' (was "${command ?? ""}")`,
@@ -1,5 +1,6 @@
1
1
  import type { ToolState } from "#app/toolState";
2
2
  import type { ResolvedPayload } from "#app/utils/config/payload";
3
+ import type { OctokitWithPlugins } from "#app/utils/github/github";
3
4
 
4
5
  /**
5
6
  * The cwd-scoped, GitHub-free subset of `ToolContext` that the read-only
@@ -79,5 +80,9 @@ export interface LocalToolContext {
79
80
  /** the repository, for its default branch. The Action's full `ToolContext`
80
81
  * carries it; a local session has none, so its remediation branches keep the
81
82
  * plain `remediate/<id>` name. */
82
- repo?: { data: { default_branch?: string | null } } | undefined;
83
+ repo?: { owner?: string; name?: string; data: { default_branch?: string | null } } | undefined;
84
+ /** the Action's GitHub client, read only to find the remediation PRs already
85
+ * open, so a group that has one keeps that PR's branch. A local session has
86
+ * none and names its branches from the group and base alone. */
87
+ octokit?: OctokitWithPlugins | undefined;
83
88
  }