Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,97 @@
CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256
checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest.json>.

The current public release is [v0.2.57 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.57),
The current public release is [v0.2.61 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.61),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.50`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.50):
npm publication is a separate, manually dispatched step, so npm can trail the
website and the GitHub release.
npm publication is a separate, manually dispatched step, so the npm `latest`
tag can trail the website and the GitHub release; the dispatch for this
version accompanies the release.
Entries explicitly marked `(unpublished)` are retained release candidates that
were superseded before distribution.

## Unreleased

No unreleased changes.
## 0.2.61 — 2026-08-08

- **Two path-traversal findings on the hook-result writer are dismissed with
their reason, in the code.** The flagged lines build a temp path from
`request.path` and open it — after that value has passed three containment
gates: the absolute-and-normalized check, lexical containment inside the
turn directory, and the deterministic attempt-location assertion. The rule
flagged the code that implements the containment, and the dismissal now
travels with the code as a reasoned `codetruss-ignore` marker, priced at
zero by scoring and shown on every receipt with the explanation above. No
behavior changes in this release; the two markers are its only code change.

## 0.2.60 — 2026-08-08

- **A dismissed finding no longer charges the score.** A finding carrying a
reasoned `codetruss-ignore` marker already stays on every receipt as
evidence and already stops gating the verdict — that is what dismissing it
is for. But `computeScores` still priced it like a live finding, which
scored this product's own repository 32 on Security over its own annotated
acceptance fixtures: planted credentials that exist to prove the analyzers
fire, each disclosed with its reason on every receipt, all charged as leaks.

Scoring now applies the same principle the verdict has applied since the
suppression mechanism was hardened: dismissed findings are evidence, not
charges. A marker without a reason never applied in the first place and
still charges; the identical finding without a marker still charges. Both
are pinned by tests, and removing the filter fails them.

## 0.2.59 — 2026-08-08

- **The first line CodeTruss prints no longer opens with a zero.** In a
repository with no `.codetruss.yml` — which is every repository the first time
someone runs it — a passing review reported `0 changed file(s) are within
approved scope; 1 more matched scope inferred from this turn`. Every word of
that is true. It also reads as "nothing was checked", which is the opposite of
what happened: the file was in scope, it was analysed, and a finding was
reported against it.

It now leads with the count actually in scope: `1 changed file(s) matched
scope inferred from this turn and disclosed on the receipt; none matched an
approved allow root`. The distinction the old sentence existed to protect is
kept — scope reached by inference is still never called approved scope, and
the receipt still lists every inferred root with the evidence it came from.
The wording says "none matched an approved allow root" rather than "this
repository approves nothing", because a repository whose allow roots simply
did not match these files is not a repository without any.

Found by installing the published tarball into a clean prefix and running
`codetruss review --staged` on `axios/axios` as a new user would, rather than
by reading the code.

## 0.2.58 — 2026-08-08

- **Prose describing a credential pattern is no longer reported as a leak.**
This scanner was run over its own source and reported three of its own doc
comments: the note explaining why `password = "password"` stays reported, and
two citing the fabricated key used to explain how typed-not-generated values
are recognized. 0.2.57 failed its own commit gate on the first of them, which
is the clearest possible statement of the problem — the comment documenting a
rule tripped the rule it documents. Any project that writes about credential
handling hits this: a security policy quoting the shape it forbids, a README
showing what not to commit, a lint rule explaining its own pattern.

A code span on a comment line is the shape, but it is deliberately NOT the
exemption, because backticks would otherwise be a place to hide a live key.
The value also has to be independently provable as not-a-credential, by one of
two anchors that already existed here and were already measured: the value
spells out its own key, so it carries no entropy the key did not already
carry; or its body was typed rather than generated, eight consecutive stepping
characters, a threshold 20,000 random bodies fail to reach. A genuinely random
value quoted in a comment satisfies neither and is still reported, as is a
commented-out assignment holding a real key — that has no code span — and the
same fabricated body in executable code, which is not prose.

The skip is announced rather than silent, at INFO, exactly as a
credential-shaped placeholder already is. A secret scanner that quietly drops
lines is indistinguishable from one that never read them.

Measured before shipping on the adjudicated cross-tool corpus plus six more
repositories — about 26,000 files, 235 findings — where nothing changed at
all. The only findings this moves are the three in our own source.

## 0.2.57 — 2026-08-08

Expand Down
10 changes: 9 additions & 1 deletion packages/analyzer-engine/src/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,15 @@ function deduct(findings: AnalyzerFinding[], categories: string[], totalLoc: num
const NEUTRAL_SCORE = 50

/** Deterministic 0-100 scores derived from findings + repo shape. */
export function computeScores(index: RepoIndex, findings: AnalyzerFinding[]): Scores {
export function computeScores(index: RepoIndex, allFindings: AnalyzerFinding[]): Scores {
// A finding a developer dismissed with a reasoned `codetruss-ignore` marker
// stays on every report as evidence — but it stops scoring, for the same
// reason it already stops gating the CLI verdict: that is what dismissing it
// is for. Charging for it anyway scored this product's own repository 32 on
// Security over its own annotated acceptance fixtures — planted, reasoned,
// and disclosed on every receipt, yet priced like leaks. Markers without a
// reason never applied in the first place (suppression.ts) and still charge.
const findings = allFindings.filter((f) => !f.suppression?.applied)
const loc = index.totalLoc
const debt = deduct(findings, ['TECH_DEBT', 'DUPLICATION', 'DEAD_CODE'], loc)
let security = deduct(findings, ['SECURITY_HYGIENE', 'DEPENDENCY'], loc)
Expand Down
66 changes: 66 additions & 0 deletions packages/analyzer-engine/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,54 @@ function restatesItsKey(line: string, matchIndex: number, value: string): boolea
return valueWords.length >= 2 && isWordRun(valueWords, identifierWords(assignedKey(line, matchIndex)))
}

/**
* Prose quoting a credential pattern in order to describe it, rather than an
* assignment that sets one.
*
* This scanner was run over its own source and reported three of its own
* doc comments: the note on restatesItsKey, which quotes `password =
* "password"` to explain why that shape stays reported, and two that cite
* `AKIA1234567890ABCDEF` while explaining how fabricated keys are recognized.
* CLI 0.2.57 failed its own gate on the first of them.
*
* A code span on a comment line is the shape, but it is NOT the exemption —
* backticks alone would be a place to hide a live key. The exemption is that
* the value is independently provable as not-a-credential, by one of two
* anchors that already exist here and are already measured:
*
* - {@link hasSyntheticSequence}: the body was typed, not generated. Eight
* consecutive stepping characters; 20,000 random bodies produce zero hits.
* - The one-word restatement from {@link restatesItsKey}: the value spells
* out its own key, so it carries no entropy the key did not already carry.
*
* Every other half stays reported, and each closes a different hole. A
* commented-out assignment holding a real key (`// const apiKey =
* "sk-live-…"`) has no code span. A template literal in executable code is not
* on a comment line. A genuinely random value quoted in a comment satisfies
* neither anchor.
*
* Comment detection is deliberately the unambiguous prefix only. Tracking open
* block comments across lines would let a stray `/*` inside a string silence
* every line after it, and a wrong answer here hides a credential.
*/
function documentsAnExample(line: string, match: RegExpMatchArray, subject: string | null): boolean {
const matchIndex = match.index ?? 0
const trimmed = line.trimStart()
if (!trimmed.startsWith('*') && !trimmed.startsWith('//') && !trimmed.startsWith('#')) return false

// Odd backtick count before the match means it opened inside a span; a
// closing backtick after it means the span encloses the match.
const openedBefore = (line.slice(0, matchIndex).match(/`/g) ?? []).length % 2 === 1
if (!openedBefore || !line.slice(matchIndex + match[0].length).includes('`')) return false

if (subject !== null && hasSyntheticSequence(subject)) return true

const quoted = /['"]([^'"]+)['"]/.exec(match[0])?.[1]
if (quoted === undefined) return false
const valueWords = identifierWords(quoted)
return valueWords.length === 1 && isWordRun(valueWords, identifierWords(assignedKey(line, matchIndex)))
}

/** Dev/CI dummy hosts — credentials pointing here are not real secrets. */
const DUMMY_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'host.docker.internal'])

Expand Down Expand Up @@ -489,6 +537,24 @@ export const secretsAnalyzer: Analyzer = {
}))
break
}
// Announced rather than silent, for the same reason the placeholder
// skip above is: a secret scanner that quietly drops lines is
// indistinguishable from one that never read them.
if (documentsAnExample(line, match, subject)) {
report(() => ({
category: 'SECURITY_HYGIENE',
severity: 'INFO',
title: `Documented ${name} example ignored in ${file.path.split('/').pop()}`,
description: `Line ${i + 1} of ${file.path} matches a ${name} pattern inside a code span on a comment line, and the value is provably not a credential — it either spells out its own key or was typed rather than generated. It is NOT reported as a leak. Shown only to confirm the scanner read this line.`,
filePath: file.path,
line: i + 1,
suggestion: 'No action needed. A random value in the same position, or the same value outside a comment, would be reported.',
impactScore: 5,
effort: 'low',
metadata: { credentialType: name, documentedExample: true },
}))
break
}
// Publishable client identifiers, before any severity is assigned —
// including the `.env` escalation below, which is what made these
// CRITICAL. The file being a committed `.env` is not evidence about a
Expand Down
82 changes: 82 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,88 @@ checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest

## Unreleased

## 0.2.61 — 2026-08-08

- **Two path-traversal findings on the hook-result writer are dismissed with
their reason, in the code.** The flagged lines build a temp path from
`request.path` and open it — after that value has passed three containment
gates: the absolute-and-normalized check, lexical containment inside the
turn directory, and the deterministic attempt-location assertion. The rule
flagged the code that implements the containment, and the dismissal now
travels with the code as a reasoned `codetruss-ignore` marker, priced at
zero by scoring and shown on every receipt with the explanation above. No
behavior changes in this release; the two markers are its only code change.

## 0.2.60 — 2026-08-08

- **A dismissed finding no longer charges the score.** A finding carrying a
reasoned `codetruss-ignore` marker already stays on every receipt as
evidence and already stops gating the verdict — that is what dismissing it
is for. But `computeScores` still priced it like a live finding, which
scored this product's own repository 32 on Security over its own annotated
acceptance fixtures: planted credentials that exist to prove the analyzers
fire, each disclosed with its reason on every receipt, all charged as leaks.

Scoring now applies the same principle the verdict has applied since the
suppression mechanism was hardened: dismissed findings are evidence, not
charges. A marker without a reason never applied in the first place and
still charges; the identical finding without a marker still charges. Both
are pinned by tests, and removing the filter fails them.

## 0.2.59 — 2026-08-08

- **The first line CodeTruss prints no longer opens with a zero.** In a
repository with no `.codetruss.yml` — which is every repository the first time
someone runs it — a passing review reported `0 changed file(s) are within
approved scope; 1 more matched scope inferred from this turn`. Every word of
that is true. It also reads as "nothing was checked", which is the opposite of
what happened: the file was in scope, it was analysed, and a finding was
reported against it.

It now leads with the count actually in scope: `1 changed file(s) matched
scope inferred from this turn and disclosed on the receipt; none matched an
approved allow root`. The distinction the old sentence existed to protect is
kept — scope reached by inference is still never called approved scope, and
the receipt still lists every inferred root with the evidence it came from.
The wording says "none matched an approved allow root" rather than "this
repository approves nothing", because a repository whose allow roots simply
did not match these files is not a repository without any.

Found by installing the published tarball into a clean prefix and running
`codetruss review --staged` on `axios/axios` as a new user would, rather than
by reading the code.

## 0.2.58 — 2026-08-08

- **Prose describing a credential pattern is no longer reported as a leak.**
This scanner was run over its own source and reported three of its own doc
comments: the note explaining why `password = "password"` stays reported, and
two citing the fabricated key used to explain how typed-not-generated values
are recognized. 0.2.57 failed its own commit gate on the first of them, which
is the clearest possible statement of the problem — the comment documenting a
rule tripped the rule it documents. Any project that writes about credential
handling hits this: a security policy quoting the shape it forbids, a README
showing what not to commit, a lint rule explaining its own pattern.

A code span on a comment line is the shape, but it is deliberately NOT the
exemption, because backticks would otherwise be a place to hide a live key.
The value also has to be independently provable as not-a-credential, by one of
two anchors that already existed here and were already measured: the value
spells out its own key, so it carries no entropy the key did not already
carry; or its body was typed rather than generated, eight consecutive stepping
characters, a threshold 20,000 random bodies fail to reach. A genuinely random
value quoted in a comment satisfies neither and is still reported, as is a
commented-out assignment holding a real key — that has no code span — and the
same fabricated body in executable code, which is not prose.

The skip is announced rather than silent, at INFO, exactly as a
credential-shaped placeholder already is. A secret scanner that quietly drops
lines is indistinguishable from one that never read them.

Measured before shipping on the adjudicated cross-tool corpus plus six more
repositories — about 26,000 files, 235 findings — where nothing changed at
all. The only findings this moves are the three in our own source.

## 0.2.57 — 2026-08-08

- **An error-code enum is no longer three leaked passwords.** A value that
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codetruss/cli",
"version": "0.2.57",
"version": "0.2.61",
"description": "Local-first scope, quality, and verification receipts for coding agents",
"license": "SEE LICENSE IN LICENSE",
"type": "module",
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/scripts/docs-profile-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,19 @@ const REGISTRY_ENTRY = /^\s*([A-Za-z][\w]*),\s*$/gm

/** Every `local-registry-vN` written down in prose. */
const PROFILE_ID_CLAIM = /local-registry-v\d+/g
/** `15-pass`, `15 registry analyzers`, `15 deterministic registry analyzers`, `15-analyzer`. */
const ANALYZER_COUNT_CLAIM = /\b(\d+)[- ](?:pass(?:es)?|(?:deterministic )?(?:registry |local )?analyzers?)\b/gi
/**
* `15-pass`, `15 registry analyzers`, `15 deterministic registry analyzers`,
* `15-analyzer`.
*
* The leading lookbehind is what makes this safe to point at the whole
* repository. `\b` alone starts a match mid-number, so `48,692 analyzer-counted
* LOC` in a blog post read as a claim of "692 analyzers" and `a pre-0.2.40 pass`
* in a test name read as "40 passes". Both are real text in this repo, and both
* would have fired the moment coverage widened — which is how a guard gets
* called noisy and switched off.
*/
const ANALYZER_COUNT_CLAIM =
/(?<![\d.,])(\d+)[- ](?:pass(?:es)?|(?:deterministic )?(?:registry |local )?analyzers?)\b/gi

function lineOf(text, index) {
return text.slice(0, index).split('\n').length
Expand Down
Loading