Skip to content

candor-agents v0.27.0

Choose a tag to compare

@tombaldwin tombaldwin released this 09 Aug 19:52
· 46 commits to main since this release

[0.27.0] — 2026-08-07

  • A config-driven exit no longer leaves a stale GREEN verdict at the file sink. The collision
    pre-pass loads .candor/config to learn what the sink must not overwrite, and re-raised SystemExit
    — so an unreadable config exited THERE, before arm_gate_json, leaving a previous run's ok: true
    intact on disk. SPEC §3.3 names that outcome: "a refusal that writes nothing leaves the previous run's
    green document on disk." The pre-pass now loads leniently and the arming happens; the real load
    refuses a moment later with the sink armed. Nothing is lost, because a config nobody can read declares
    no inputs anyone can name. Same defect, same fix, as candor-ts.

  • ⟨0.27⟩ The stream sink and zeroMatch (SPEC §3.1/§4, conformance PART 36). (1) --gate-json -
    now carries the fail-closed refusal document on the exit-2 causes that fired before the gate tail —
    a usage error (unknown flag, valueless flag, missing target) and an unreadable policy — instead of
    leaving stdout empty; a file sink gets the specific reason in place of the armed placeholder on the
    unreadable-policy cause. (2) zeroMatch: the §4 zero-match list now rides the verdict document
    (code-point sorted, deduplicated, omitted when empty); it was stderr-only in all five engines.

  • ⚠ ⟨0.24⟩ A POLICY THIS ENGINE COULD NOT HONOUR WAS SILENTLY REWRITTEN INTO A WEAKER ONE.
    deny Frobnicate was dropped with a stderr note and the run exited 0 printing policy ✓ — the
    operator reads an armed gate that does not exist, which is the fail-open SPEC §6.2 ⟨0.24⟩ closed in
    the four code engines by refusing (exit 2, the unreadable-policy posture). Found while measuring the
    PART 36 cells. The FATAL set now matches theirs on every token it shares, which is not the same as "token for
    token" — an earlier draft of this sentence said that and it was false in two places, both deliberate
    and both worth stating rather than quietly matching:

    • deny Net[<class>] is HONOURED (scoped, exit 0) by candor-classify for a class it recognises and
      FATAL only for one it does not; here EVERY bracketed form is WIDENED — the filter dropped, the rule
      enforced unscoped — with a stderr line saying so. So the divergence is wider than a fatal-set
      mismatch: a filter candor-classify would apply, this engine discards.

      (This sentence has now been wrong twice. It first claimed parity "token for token"; the correction
      then said candor-classify treats the whole bracketed form as fatal, which is true only for an
      unrecognised class — measured: deny Net[known-telemetry] and deny Net[*] exit 0 there,
      deny Net[telemetry] exits 2. Writing a comparison to another engine without running that engine
      is what produced both.) This engine emits no netClass, so honouring the
      filter would match an absent field and PASS — fail-open — and dropping the whole rule fails open
      too. §3.1's policy-side rule is that a dropped token leaves a WIDER rule standing, so widening is
      the third road and the only closed one. A bracketed allow is refused outright rather than
      widened, because widening an allow is the direction that hides a violation.

    • allow Llm is honoured by candor-classify and refused here: ALLOW_EFFECTS is the four literal
      surfaces (Net, Exec, Fs, Db) and Llm is not among them.

    Otherwise the set is theirs (candor-classify not_honoured!(true, …)):
    a deny whose effect list ends up EMPTY, an allow naming an effect outside the four literal
    surfaces, and an unrecognised reason-class/alias inside Unknown[…] — that last one is the dangerous
    direction, since deny Unknown[dispatch,nativ] ran as Unknown[dispatch] and let every native hole
    through green. Everything else stays reported-and-survivable (allow with no values, a malformed
    forbid, an unknown rule kind), because the rest of the policy still means what it says. The refusal
    reaches BOTH sinks: the file placeholder is replaced with the specific reason, and --gate-json -
    carries the refusal document as stdout's only content. There is no composed case to weigh — this
    engine has no AS-EFF-005 baseline producer, so every violation it can establish comes from the policy
    being refused, and a bad token establishes nothing from the policy itself (§3.1); the site that would
    have to consult one is marked.

  • ⚠ This engine declared spec 0.27 and implemented none of the gate-sink layer that release adds.
    Measured: --policy P --gate-json P DESTROYED P, so the very next run of the same command exited 0
    on a fleet that violates — the gate silently gone, which is the machine-readable false all-clear the
    ⟨0.27⟩ rung exists to close. And an unknown flag beside --gate-json G exited 2 leaving the PREVIOUS
    run's green at G. Four engines gained the guard in this release; this one declared the same contract
    without it, and conformance PARTs 32/34 ran four engines, so nothing caught it.
    Now: the sink is armed fail-closed at the instant it is known — before parse_args, whose own exits
    would otherwise leave a stale green — and a sink naming an INPUT is refused with nothing written. Every
    channel: --policy, CANDOR_POLICY, CANDOR_CONFIG, the config's own policy key (the checked-in
    form, i.e. the one CI has), and any .candor/config by shape. Sameness resolves artifacts, not strings.

  • ⚠ §4 zero-match: a rule whose scope bound no unit was scored as SATISFIED. deny Exec orchestratr
    — one character — turned a failing gate green, silently. Now disclosed on stderr with the verdict and
    exit code untouched, because a zero-match rule is legitimate when one policy is shared across fleets.

  • A version is ASCII digits, and str.isdigit() is not. engine ٣.٣ (Arabic-Indic) and engine ².0
    NORMALISED as versions, so they read as a MISMATCH rather than MALFORMED — and that difference decides
    whether the "an unreadable unqualified line is not hidden by a qualified pin" rule fires. Beside a good
    qualified pin the junk line was handed over silently and the run passed at exit 0 while three
    engines exited 2. Alone, every engine already refused; only the paired shape shows it. candor-swift had
    the same defect via Character.isNumber; both fixed, five-way now, pinned by conformance PART 33.

  • An unreadable unqualified pin was hidden behind a qualified one. engine garbage beside a good
    qualified line passed silently here while candor-java exited 2 — unreadability is a property of the