Skip to content

feat(onboard): /aidlc-onboard S1 — promote core + rule route (2.5.40) - #660

Closed
alokgp wants to merge 3 commits into
awslabs:v2from
alokgp:onboard-s1-rule-route
Closed

feat(onboard): /aidlc-onboard S1 — promote core + rule route (2.5.40)#660
alokgp wants to merge 3 commits into
awslabs:v2from
alokgp:onboard-s1-rule-route

Conversation

@alokgp

@alokgp alokgp commented Jul 26, 2026

Copy link
Copy Markdown

What

Ships /aidlc-onboard slice S1 — "promote core + rule route": a customer's body of onboarding material is captured byte-exact, classified (text-only), and preventative standards are promoted into memory/{project,team}.md as rules through a human gate. #476

Two commits:

  • feat(onboard) — the S1 slice (capture / list / classify subcommands, Gap-A stage-optional persist-rule core, /aidlc-onboard skill, tests t248 + t239). Version 2.5.12.
  • fix(onboard) — hardens the binary sniff (see below).

Scope boundaries (by design)

  • Text-only classify. PDF/binary extraction is S4 — a binary source is still captured byte-exact but classifies as unsupported-binary, never coerced into a text disposition.
  • Rules route only. The knowledge/ (reference material) route is S2, not built here.
  • team/project scope only — no org tier for onboard rules (rejected outright).
  • Default space via activeSpace()/DEFAULT_SPACE; never scaffolds a per-customer space. Audit surface count unchanged (74).

Dry-run finding → fix

Ran S1 against a real folder of 3 ReportLab PDFs + 1 markdown. The markdown promoted correctly, but the PDFs leaked through as other-text instead of unsupported-binary: looksBinary() sniffed only for a NUL byte in the first 8KB, and a ReportLab PDF is FlateDecode-compressed inside an all-ASCII wrapper with no NUL byte anywhere.

looksBinary() now quarantines on any of: a known binary magic header (PDF / zip family / JPEG / PNG / GZIP), a NUL byte, or >30% non-printable bytes in the probe window. t248 gains a NUL-free-PDF regression fixture. Re-run: all 3 PDFs → unsupported-binary, markdown → preventative.

Tests

  • Drift guard (package.ts --check): clean across all 5 harnesses.
  • Smoke + unit: 178 files, 0 failures. Onboard: t248 (14) + t239 (4) pass.
  • Heads-up for CI: t89 and t66 fail on this branch but fail identically on the v2 base (257b43a3) with zero onboard code present — verified via a throwaway worktree. t89 is the claim-sources sensor not wired into the registry; t66 is a golden-fixture drift. Neither is introduced by this PR. t92 (missing tsc/eslint) and t163 (parallelism race, passes in isolation) are environmental.

@alokgp

alokgp commented Jul 26, 2026

Copy link
Copy Markdown
Author

@apackeer above is the first slice for #476, checking the documentation error - will update it.

@apackeer

Copy link
Copy Markdown
Contributor

Reviewed at head 37f675f6 (2.5.12, base 257b43a3). Every finding below was reproduced against the shipped dist tools at this head, not just read from the source. Overall: a solid first slice with scope boundaries that hold up (text-only classify, rules route only, team/project only, activeSpace() default), but I would not merge as-is - four P1s below.

P1: concurrent capture/classify silently lose manifest rows (no lock)

core/tools/aidlc-onboard.ts does read-modify-write on manifest.json with zero locking (readManifest :86 -> mutate -> writeManifest :105; no withAuditLock call anywhere in the file). writeFileAtomic prevents half-writes but not lost updates - core/tools/aidlc-lib.ts:3293-3296 documents exactly this trap ("Pair with withAuditLock when concurrent writers must serialise"), and every sibling tool that mutates shared state uses it (aidlc-graph.ts, aidlc-learnings.ts, aidlc-log.ts, aidlc-runtime.ts).

Reproduced: 6 parallel classify calls kept 3 of 6 dispositions (serial control kept 6/6); 6 parallel capture calls kept 4 of 6 rows (all 6 files landed on disk, but the ledger rows that make them reachable are gone). A second, independent repro lost 11 of 16 parallel captures. This is reachable through the documented flow: SKILL.md Step 2 says "for every captured item ... run classify --id <id>", which is exactly the shape a harness batches into one parallel tool block.

Fix: wrap each subcommand's read-modify-write in withAuditLock, matching handlePersistRule (aidlc-learnings.ts:684).

P1: captured_path/source_path are absolute machine-local paths in a committed file

aidlc/spaces/<space>/onboard/ is NOT gitignored (git check-ignore on both manifest.json and files/<sha>-*.md exits 1 against the shipped harness/claude/dot-gitignore; the gitignore's committed-vs-ignored ledger does not mention onboard/ either way). So the manifest, with rows like

"source_path":   "/tmp/onb-test/proj-a/standards.md",
"captured_path": "/tmp/onb-test/proj-a/aidlc/spaces/default/onboard/files/089e...-standards.md",

is a committed file. handleClassify reads row.captured_path verbatim (:294-298), so a teammate at a different checkout path (or a clone, move, or worktree) gets captured file missing on disk, exit 1, just by pulling. The codebase already has the pattern to avoid this: IntentRegistryEntry stores dirName rather than paths, and the relative* resolver family (aidlc-lib.ts:1120-1177) exists for this reason.

Fix: store the captured path relative to the onboard dir (or derive it at read time from onboardCapturedFilePath(), which already exists) and keep source_path as provenance only. Alternatively, decide onboard/ is ignored and add it to the gitignore ledger explicitly.

P1: the documented --candidate-id recipe silently drops all but the first rule of a doc

SKILL.md:149 tells the model to use "the manifest file id" as the candidate id. That id is per FILE; a standards doc normally yields several rules. The cid dedup keys on (namespace, candidate_id) (aidlc-learnings.ts:463-464), so every rule after the first from the same doc is indistinguishable from an idempotent re-run:

persist-rule --candidate-id aaa111 --text "Rule ONE from the doc"      -> rule_learned:1
persist-rule --candidate-id aaa111 --text "Rule TWO from the SAME doc" -> rule_learned:0, exit 0

Rule TWO is human-approved at the gate and then silently discarded - no warning, no audit. Fix: make the recipe per-candidate (e.g. <manifest-id>-<n>) and say so in Step 5. Worth also making "already there" distinguishable from a fresh write in the JSON output so the skill can report the difference at Step 6.

P1: the keyword prefilter vetoes the LLM classification it claims to feed

classifyText requires two DISTINCT imperative words (aidlc-onboard.ts:276-279). Real standards docs typically repeat ONE imperative, so:

"All requests must be authenticated. All PII must be encrypted at rest.
 All secrets must live in the secret manager."           -> other-text
"Production access must require MFA"                     -> other-text
"Getting started: you must install bun first. Always
 run the tests before you push."   (a README)            -> preventative

And SKILL.md makes other-text terminal: the LLM judgement described at :92-96 applies only to preventative items (downgrade false positives), while :97-99 says other-text must not be promoted. So a false negative here means a genuine standards doc never reaches the human gate at all - the code comment's claim that the prefilter is "not a substitute for the skill's own LLM reasoning" only holds in one direction. Also two dead alternations: shall not and must not can never match because shall and must appear earlier in the alternation and win.

Suggest: total imperative count (e.g. distinct >= 2 OR any single imperative >= 3), drop the unreachable alternatives, and/or let the skill re-judge other-text items instead of hard-forbidding promotion.

P2s

  • --space accepts raw path traversal. capture --space "../../../outside" writes the manifest and captured files outside the project dir, exit 0 (aidlc-onboard.ts:194 passes flags.space raw into the join at aidlc-lib.ts:1027), skipping the space-create slugify chokepoint. A typo also creates a partial space. Related: persist-rule silently ignores --space (parseFlags absorbs unknown keys), so capture --space acme + persist-rule --space acme lands the rule in default - either thread it or reject it.
  • Scope-change defeats idempotency. hasRow dedups the audit globally while hasLine checks only the requested destination (aidlc-learnings.ts:463-464): persisting one cid to project then team writes the line into BOTH files, reports the second call as rule_learned:0, and the team.md rule carries no audit row.
  • High-byte binaries classify as text. looksBinary counts only control bytes < 0x20 (:250-263); a NUL-free, magic-less blob of 0x80-0xfe bytes decodes to U+FFFD soup and returns other-text with the mojibake in content. A replacement-character-ratio check (or fatal UTF-8 validation, if rejecting latin-1/Windows-1252 docs is an acceptable and stated S1 trade-off) would catch it.
  • classify emits the whole file into the model's context, uncapped. The full decoded file goes into the JSON content field (:300-316); measured 2.24M chars (~560k tokens) from one 2.2MB text file, and Step 2 runs classify on every captured item. Suggest a cap plus an explicit truncated: true flag.
  • Untrusted document text reaches the rule writer unsanitised. Both reproduce through the pre-existing persist path, but onboard changes the threat model (the text now originates in a customer-supplied doc): (a) a newline in --text splits one approved rule into two practice bullets, the first carrying no cid marker (invisible to dedup, cannot be undone by re-running); (b) doc text containing <!-- cid:aidlc-onboard:<id> --> pre-suppresses that future candidate id - reproduced: the later legitimate call returned rule_learned:1 and emitted the audit row but wrote nothing. Rejecting \r|\n|<!-- cid: in --text and enforcing /^[A-Za-z0-9._-]+$/ on --candidate-id in handlePersistRule closes both on the new path.

P3s / smaller

  • core/templates/onboarding.md (renders to dist/claude/.claude/CLAUDE.md and every AGENTS.md) never mentions onboard, so the agent-facing inventory does not know the feature exists.
  • SKILL.md Step 5 never passes --source, so the audit row's Source defaults to "onboard" (aidlc-learnings.ts:680) and no filename/path provenance reaches the audit.
  • walkFiles uses statSync (:128): a symlink loop dies with a raw ELOOP stack trace, a broken symlink with ENOENT - not the clean stderr+STOP the skill promises. lstatSync + skip.
  • Capturing the repo root re-ingests aidlc/ itself: each run captures the previous run's manifest, +1 row per run, unbounded. Prune the workspace dir.
  • t227 (tool entrypoint exports) and t230 (dispatcher routes) hardcode tool filename lists that omit aidlc-onboard.ts; the tool already satisfies both contracts, so these are two-line coverage additions.
  • Doc sweep gaps: core/knowledge/aidlc-shared/audit-format.md:192 and docs/reference/12-state-machine.md still name aidlc-learnings.ts persist as the only RULE_LEARNED emitter; docs/reference/06-hooks-and-tools.md:523 lacks persist-rule and has no aidlc-onboard.ts section; the docs/guide/03-spaces-and-intents.md space tree lacks onboard/.
  • onboard-design.md is cited 11 times across shipped code and tests and does not exist in the repo (git ls-files | grep onboard-design returns nothing).
  • RULE_LEARNED rows with Stage: aidlc-onboard are invisible to the workflow rollup/replay (dropped by the verbatim slug match at aidlc-runtime.ts:694) while SKILL.md:49 and docs/guide/12-cli-commands.md:668 imply parity with the learning loop. Probably intended; one sentence in the docs would settle it.
  • Version slot 2.5.12 is also claimed by another open PR - second to merge re-bumps and renames its CHANGELOG heading per the repo's conflict-trap note.
  • New test tests/integration/t239-onboard-skill-flow.test.ts collides on number with tests/unit/t239-documentation-parity.test.ts - legal, but a free number avoids the confusion.

Verified correct (checked, no action needed)

  • The handlePersist -> writeRulePractice refactor is behavior-preserving: differentially tested old vs new dist tools on identical fixtures (no-op / fresh / recovery / mixed selections), practice files and audit blocks byte-identical.
  • The ONBOARD_RULE_NAMESPACE sentinel's claims hold (no shipped stage slug is aidlc-onboard; plugin slugs are prefixed) - though note it is convention-backed, not schema-enforced.
  • --scope org rejects with exit 2 and writes nothing (t248 covers it).
  • All 5 harnesses received the skill; opencode's readdir-derived adapter allowlist picked the tool up automatically.
  • The binary magic-header check is load-bearing: a NUL-free %PDF fixture would classify other-text without it (t248 covers it).

Gates run locally

bun scripts/package.ts --check OK on all 5 trees; typecheck clean; smoke+unit 178 files / 0 failed; integration 104 files / 3 failed (t66, t72, t89) - all 3 reproduced on a clean 257b43a3 base with zero onboard code, so they are pre-existing, matching the PR body's claim (t72 included). The PR's own t248 and t239 pass.

@leandrodamascena leandrodamascena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I independently validated Arden’s findings against head 37f675f6; all four P1s reproduce. I also found three additional blockers affecting security and audit correctness.

1. [P1] Treat captured_path as untrusted and portable

core/tools/aidlc-onboard.ts:289-305

The committed manifest stores an absolute, machine-local captured_path, and classify reads that path verbatim.

I confirmed two failures:

  • After moving the workspace, classify failed because the manifest still referenced the original checkout.
  • After changing captured_path to another readable local file, classify returned that file’s contents in its JSON output.

This turns a committed manifest into an arbitrary local-file read primitive. Store a relative path or derive it from the row ID and captured filename. Before reading, resolve the path, verify containment under onboard/files, and confirm that its SHA-256 still matches the manifest.

2. [P1] Do not interpolate document-derived content into shell commands

core/skills/aidlc-onboard/SKILL.md:144-152

The skill inserts the candidate text into:

--text "<candidate text, verbatim>"

Double quotes do not neutralize command substitution. Customer-derived content containing $() or backticks executes when the harness runs the documented command.

I reproduced this with the documented command shape: $(touch <marker>) executed successfully while persist-rule exited 0.

The Step 1 source path has the same problem because <path> is unquoted. Pass source paths and candidate data through a structured JSON file or stdin instead of constructing a shell command from untrusted text.

3. [P1] Serialize the complete manifest transaction

core/tools/aidlc-onboard.ts:194-204, core/tools/aidlc-onboard.ts:282-309

Both capture and classify perform an unlocked read-modify-write of the shared manifest. Atomic replacement prevents partial files, but it does not prevent lost updates. All processes also use the same manifest.json.tmp path.

My parallel probes produced:

  • 16 captures: only 6 ledger rows survived.
  • 16 classifications: only 7 dispositions survived.
  • Several processes failed because competing writers renamed or removed the shared temporary file.

This is reachable through the documented workflow because Step 2 asks the harness to classify every captured item, which can naturally become a parallel tool batch.

Use a per-space lock covering manifest read, mutation, captured-file write and manifest replacement. Unique temporary filenames are also needed, but they do not replace serialization.

4. [P1] Keep onboarding audit identity stable after an intent is created

core/tools/aidlc-learnings.ts:682-700

Before a workflow exists, persist-rule emits RULE_LEARNED into the bare space audit directory. After an intent is created, readAllAuditShards(projectDir) resolves only the active intent’s audit directory and no longer sees the original onboarding event.

I reproduced this sequence:

  1. Persist a rule in a bare project: rule_learned: 1.
  2. Create the first intent.
  3. Persist the same candidate ID again.
  4. The second invocation incorrectly returns rule_learned: 1.

The rule line remains deduplicated, but a duplicate audit event is emitted because the original event became invisible. Define a stable space-level audit surface for pre-workflow events or explicitly search both the pre-workflow and active-intent buckets.

5. [P1] Generate a unique candidate ID for each proposed rule

core/skills/aidlc-onboard/SKILL.md:147-159

The skill suggests using the manifest file ID as --candidate-id. That ID identifies the document, while one document commonly contains several independent standards.

I confirmed:

Rule one, candidate ID = file hash -> rule_learned: 1
Rule two, same file hash         -> rule_learned: 0

Only the first approved rule was written. The second was silently interpreted as an idempotent replay.

Generate a stable per-rule identifier, such as <manifest-id>-<candidate-index> or a digest of the normalized candidate. The tool should also distinguish “already persisted” from “newly persisted” clearly enough for the final summary.

6. [P1] Do not let the keyword prefilter veto genuine standards

core/tools/aidlc-onboard.ts:266-280, core/skills/aidlc-onboard/SKILL.md:89-113

classifyText requires two distinct imperative keywords. A document that repeatedly uses one normative term, or contains one concise rule, becomes other-text.

For example:

Passwords must contain at least 14 characters.

This was classified as other-text. The skill then explicitly prohibits drafting a candidate from that disposition, so the model and human gate never get an opportunity to correct the false negative.

Either let the model review every textual item, or change the heuristic so a clear normative statement can reach the gate. The prefilter can safely downgrade workload, but it should not be the final veto while being described as merely a signal.

7. [P2] Validate text decoding before classification

core/tools/aidlc-onboard.ts:244-264

The binary detector counts only low control bytes. High bytes such as 0xff are not considered non-printable, and Buffer.toString("utf-8") silently replaces invalid sequences.

I classified a NUL-free file containing only 0xff bytes. It returned:

{
  "disposition": "other-text",
  "content": "��������..."
}

Use fatal UTF-8 decoding or reject content with an excessive replacement-character ratio. Also consider binary payloads whose magic bytes or NULs occur after the current 8 KiB probe window.

Verification

  • Onboarding tests: 18 passed.
  • Learning-loop regression tests: 41 passed.
  • Package parity across all harnesses: passed.
  • PR CI: green.
  • Review worktree: clean.

The existing tests validate the happy path but do not exercise adversarial manifests, shell metacharacters, concurrent writers, workspace relocation, multi-rule documents, intent creation between retries, or invalid UTF-8.

alokgp pushed a commit to alokgp/aidlc-workflows that referenced this pull request Jul 29, 2026
Addresses PR awslabs#660 review findings from apackeer (4 P1 / 5 P2 / ~11 P3) and
leandrodamascena (CHANGES_REQUESTED, 3 further blockers). Every finding was
reproduced against the shipped dist tools and is now covered by a test, so the
class is closed rather than the instance. No new mechanism is introduced — the
fixes tighten the stage-optional persist core and the capture ledger the design
already specifies.

Security and correctness:

* Document-derived rule text no longer rides a shell command line. A harness's
  shell expands $() and backticks in a --text argument before the tool starts,
  so no tool-side validation can defend it; persist-rule gains --text-file
  <path> so only a path reaches the command line and the untrusted bytes arrive
  through a file read, where they are inert. --text remains for caller-authored
  text. Trailing newlines in the file are stripped; an interior newline is still
  rejected, since a rule is one practice line.
* The capture manifest is committed, so it is untrusted input. classify now
  resolves each row, requires containment under onboard/files/ both before and
  after symlink resolution, and verifies the bytes' sha256 against the row —
  closing an arbitrary-local-file read via a tampered manifest, an in-tree
  symlink escape, and one row impersonating another's bytes.
* A rule persisted before any workflow stays deduplicated once the first intent
  exists: readPreWorkflowAuditSurface reads the union of the space-level and
  active-intent audit buckets, so a replayed candidate id no longer re-emits a
  duplicate RULE_LEARNED.
* Manifest read-modify-write runs inside withAuditLock, re-reading in the lock
  body; atomic writes use per-process temp names so concurrent writers cannot
  remove each other's scratch file.
* The ledger stores captured paths relative to the onboard dir, so a clone,
  move, or worktree at another path resolves the same rows.

Classification and input handling:

* The preventative prefilter is recall-biased (two distinct imperatives OR one
  repeated three times) and the skill re-judges both text dispositions, so a
  genuine standard written with one repeated imperative still reaches the human
  gate. Unreachable regex alternations removed.
* The binary sniff scans the whole buffer for a NUL rather than the first 8KiB,
  and quarantines high-byte blobs by replacement-character ratio.
* classify caps the content it returns and sets truncated: true.
* Candidate ids are per rule, not per file, and the reply carries
  already_present so an idempotent re-run is distinguishable from a fresh write.
* --space must be a bare slug; persist-rule rejects --space rather than
  ignoring it; rule text rejects newlines and the cid marker syntax.
* The directory walk uses lstatSync, skips symlinks, and prunes aidlc/ so
  capturing a project root cannot re-ingest the ledger.

Coverage and docs:

* t248 grows to 39 tests with an adversarial block (tampered manifest, shell
  metacharacters via --text-file, deep NUL, lifecycle-boundary replay, parallel
  survival); 98/98 learning-loop tests still pass, so the writeRulePractice
  refactor is non-regressive.
* Routed docs, the agent-facing inventory, the audit-format and state-machine
  emitter lists, and all five gitignore committed-ledgers updated; the
  integration test moves off its t239 collision to t240.
@alokgp
alokgp force-pushed the onboard-s1-rule-route branch from 37f675f to a40892b Compare July 29, 2026 02:27
@alokgp

alokgp commented Jul 29, 2026

Copy link
Copy Markdown
Author

Fixed at head a40892b9 (2.5.12, rebased onto 207db2ea). Every finding from both reviews was
reproduced against the shipped dist tools before and after the fix, and each is now pinned by a test
in the tier CI gates on. No new mechanism was introduced — the fixes tighten the stage-optional
persist core and the capture ledger S1 already ships; the one added flag is --text-file.

Numbering follows @leandrodamascena's review; @apackeer's four P1s are items 1, 3, 5 and 6.

1. [P1] Fixed: captured paths are relative, contained, and digest-verified

core/tools/aidlc-onboard.ts:158-201, core/tools/aidlc-lib.ts:1048

A row now stores captured_file relative to the onboard dir (onboardRelativeCapturedFile), and
source_path is provenance only — never resolved. That fixes portability; it does not by itself fix
the read primitive, so resolveVerifiedCapturedPath also: resolves the path, requires containment
under onboard/files/, calls realpathSync (:188), re-checks containment on the dereferenced
path
, and verifies the bytes' sha256 against the row (:482).

Both extra checks are load-bearing. A symlink planted inside files/ passes a raw string
containment test, and containment alone still permits one row pointing at a sibling captured file.
Reproduced after the fix — all three exit 1 with the foreign contents absent from stdout:

captured_file: ../../../../../company-secret.txt  -> exit 1, "points outside the onboard files dir"
captured_file: files/<symlink-out>                -> exit 1, "resolves outside ... via a link"
captured_file: <a sibling row's file>             -> exit 1, "does not match its ledger digest"

A row with no captured file at all names a remedy rather than a field (re-capture the source ... capture --source '<path>'), and list still works on it.

2. [P1] Fixed: document-derived text no longer reaches a shell

core/tools/aidlc-learnings.ts:724-745, core/skills/aidlc-onboard/SKILL.md

Correct that this was unreachable from inside the tool — the shell expands $() before the process
starts, so the round-1 --text validation could never have caught it. persist-rule gains
--text-file <path>: the same single-rule shape, with the rule text read from a file the skill
writes with its file-writing tool (explicitly not a heredoc). Only a path reaches the command line.
--text remains for caller-authored text.

Trailing newlines are stripped (a file-write tool almost always adds one); an interior newline is
still rejected, since a rule is one practice line. Step 1's <path> is single-quoted too — that was
the second interpolation point in the same file.

Reproduced after the fix: a candidate carrying $(touch /tmp/marker) writes no marker file and lands
verbatim in project.md.

3. [P1] Fixed: the whole manifest transaction is serialised

core/tools/aidlc-onboard.ts:203-224, core/tools/aidlc-lib.ts:3372

Every read-modify-write runs inside withAuditLock via inManifestLock, re-reading the ledger
inside the lock body — no decision on a pre-lock read. Reproduced after the fix: 6 parallel
capture keeps 6/6 rows (was 4/6) and 6 parallel classify keeps 6/6 dispositions (was 3/6).

Also fixed the shared temp filename, which is what produced the spurious write failures: atomicTmpPath
now emits <path>.<pid>-<n>.tmp. Measured on 240 concurrent writes across 6 processes — 166
failures
with the old fixed .tmp, 0 with per-process names. Defence in depth; the lock is
still what prevents lost updates.

4. [P1] Fixed: onboarding audit identity survives intent creation

core/tools/aidlc-lib.ts:2183, core/tools/aidlc-learnings.ts:786

New readPreWorkflowAuditSurface() reads the union of the space-level and active-intent audit
buckets, so a pre-workflow RULE_LEARNED stays visible once the first intent exists. Reproduced
after the fix — persist, create the intent, re-persist the same id: rule_learned: 0,
already_present: true, and exactly one audit row across both buckets.

The tell was a round-1 field: already_present: true disagreeing with rule_learned: 1 is what
exposed the blind lookup. priorAuditRow (:358) is now also scoped per destination, so persisting
one id to project then team writes each file with its own audit row instead of reporting the
second as done.

5. [P1] Fixed: candidate ids are per rule, and no-ops are visible

core/skills/aidlc-onboard/SKILL.md, core/tools/aidlc-learnings.ts:522

The documented recipe is now <manifest-id>-<n> — per candidate, not per file — and SKILL.md states
that one standards document normally yields several rules. The reply carries already_present
alongside rule_learned, so an idempotent replay is distinguishable from a fresh write and the
skill's summary must report it as such.

Reproduced against real material: a 21KB Angular coding-standards doc yielded 4 rules, all
written
, across three headings and two scopes.

6. [P1] Fixed: the prefilter no longer vetoes genuine standards

core/tools/aidlc-onboard.ts:442-452, core/skills/aidlc-onboard/SKILL.md

The heuristic is recall-biased: two distinct imperatives OR one repeated three times. The
unreachable shall not / must not alternations are gone (the single words matched first). More
importantly SKILL.md now has the model re-judge both text dispositions, so other-text is no
longer terminal — a concise standard the keyword count misses still reaches the human gate.

7. [P2] Fixed: decoding is validated, and the probe window is gone

core/tools/aidlc-onboard.ts:387-430

Three changes: a NUL disqualifies wherever it appears rather than only in the first 8KiB
(hasNulByte), a replacement-character ratio check catches NUL-free high-byte blobs
(replacementRatio), and the emitted content is capped with truncated: true (:460) so one large
file cannot exhaust the model's context.

Reproduced after the fix: 'A'*9000 + NUL*500unsupported-binary (was other-text); a
0x80-0xfe blob → unsupported-binary; a 700KB text file → truncated: true with the disposition
still computed over the full text.

Remaining P2s / P3s from round 1

--space must be a bare slug (aidlc-onboard.ts:140, aidlc-lib.ts:1038) — a traversal value exits
2 instead of writing outside the project; persist-rule rejects --space (:774) rather than
absorbing it; lstatSync + symlink skip (:247, :317) and aidlc/ pruned from the walk (:239)
so capturing a project root cannot re-ingest the ledger; --source threaded so provenance reaches
the audit; t227/t230 tool lists include aidlc-onboard.ts; the rollup non-parity stated in
SKILL.md, docs/guide/12-cli-commands.md and audit-format.md; doc sweep across audit-format,
12-state-machine, 06-hooks-and-tools, 03-spaces-and-intents and the agent-facing
onboarding.md; onboard/** added to all five gitignore committed-ledgers; the integration test
renumbered off its t239 collision to t240; and all 11 onboard-design.md citations removed from
shipped code (as its my personal feature design document as I am implementing the onboard feature) — that file only ever existed in a gitignored designs/ dir, so it was an unresolvable pointer for every reader.

Verification

t248 grew 30 → 40 tests with an adversarial block: concurrent-writer survival for both
subcommands, tampered manifest (traversal / symlink / digest / no-captured-file), shell
metacharacters through --text-file, interior-newline rejection, deep NUL and high-byte blobs,
multi-rule-per-document ids, and an idempotent replay across intent creation. 98/98 learning-loop
regression tests still pass, so the writeRulePractice refactor is non-regressive.

Also ran the documented flow against a real installdist/claude/ copied into a fresh project,
pointed at a real customer folder (one 21KB Angular standards doc + three PDFs). 4/4 captured, all
three PDFs quarantined as unsupported-binary, 4 rules promoted from the one text doc, 4 audit rows
each naming the real source file, and after compile the onboarded team.md/project.md resolve in
rules_in_context for 32/32 stages — the rules are live, not merely written. Replay was a clean
no-op, re-capture deduplicated on sha256, and nothing beyond manifest.json + files/ lands under
onboard/. 52/52 checks green against this exact commit. (That pass is deterministic — candidate
lines drafted from the document rather than by a live model, and the gate not rendered; both stay
covered by the SDK tier.)

Gates run locally

bun scripts/package.ts --check OK on all 5 trees; typecheck + lint clean; smoke+unit 179 files /
0 failed
(4477 assertions); changelog guard OK; docs zensical --strict clean.

Integration-tier failures, separated by class so they are not conflated:

  • Pre-existing: t66, t89, t92 — 21 failures, identical on a clean 207db2ea worktree and on this branch.
  • Parallelism flakes: t20, t21, t25, t70 and the sdk/journey set — fail under --parallel 4, pass serially.
  • LLM-tier flake: t72 is non-deterministic on both trees — passed once here (591s), then failed at 887s on askedQuestions.length === 0; a clean 207db2ea worktree fails identically (same assertion, same ~887s, zero onboard code present), matching the file's own "Known LLM-tier flake — re-run alone" note.

CI's PR gate is --smoke --unit, so the integration tier is not a merge gate either way.

Keeping these classes closed

The durable outcome is in t248, not in a promise: the adversarial block is committed alongside the
fixes and fails if any of these regress. Two habits changed as well, and they are what actually found
things — dry-running against real customer material on a fresh dist/ install rather than only
in-repo fixtures (that is what surfaced the NUL-free PDF leak), and reproducing every reported finding
against the shipped tools before and after the fix. Both reviews landed findings that passed the
existing completeness checklist, so for anything that writes shared state, persists a path, takes
external input, or hands an LLM a recipe, the bar is now "has an adversarial test" rather than "looks
correct".

One housekeeping item: 2.5.12 is also claimed by #664 and #658. Upstream is still 2.5.11 so this
branch is currently valid; whoever merges second re-bumps and renames their CHANGELOG heading per the
repo's conflict-trap note. I can take that side if this one lands later.

@apackeer apackeer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-checked the round-1 findings against head a40892b9 and ran the focused
tests. The happy-path fixes are present, but several of the reported classes are
not closed yet.

1. [P1] Anchor containment above onboard/files

core/tools/aidlc-onboard.ts:173-195

The post-realpath check trusts realpath(filesRoot) as the containment root.
If the committed onboard/files entry is itself a symlink, both realRoot and
the candidate resolve outside the project and the check passes.

I replaced onboard/files with a symlink to a directory containing
secret.txt, added a digest-matching ledger row for files/secret.txt, and ran
classify. It exited 0 and returned the external file contents in content.
The existing test covers a symlink inside a trusted files/ directory, but
not a symlink at that trust boundary. Reject a symlinked files root, or prove
the real files root is itself contained under a trusted real onboard root,
before accepting descendants.

2. [P1] Single quotes do not make arbitrary paths shell-safe

core/skills/aidlc-onboard/SKILL.md:60-70, :182-189

The review asked that source paths and document text not be interpolated into a
shell command. --text-file fixes the text half, but the skill still inserts
source_path as --source '<path>'. A POSIX filename may contain ', which
closes that quote.

I captured a safely named directory containing a file named
policy'; touch pwned-from-source; #.md, then followed the Step 5 command shape
with its ledger source_path. persist-rule exited 0 and
pwned-from-source was created. The Step 1 command has the same issue for a
direct source path. Pass paths through a structured/file input too, or use an
argument-vector API; literal single-quote wrapping is not an encoding.

3. [P2] --space still creates typo spaces

core/tools/aidlc-onboard.ts:140-149, :314-331

resolveSpaceFlag checks only the slug shape; it does not verify the stated
"existing space" requirement. On a bare project,
capture --space typo-space exits 0 and creates
aidlc/spaces/typo-space/onboard/manifest.json. This leaves the partial-space
case from the original P2 unresolved.

4. [P2] Invalid UTF-8 after 8 KiB still classifies as text

core/tools/aidlc-onboard.ts:394-411

The NUL scan is now whole-buffer, but the control-byte and replacement-character
checks still inspect only buf.subarray(0, 8192). A file containing 9,000 ASCII
bytes followed by 50,000 0xff bytes exits 0 as other-text and returns 50,000
replacement characters in content. Apply decoding validation to the whole
buffer (or use a fatal decoder); the probe window has not actually gone away.

5. [P2] Destination-scoped audit dedup is machine-path dependent

core/tools/aidlc-learnings.ts:484-490, :506-515

The scope-change fix matches the prior row against the absolute
Destination. After persisting a rule, copying/moving the project, and
replaying the same candidate, I get already_present: true but
rule_learned: 1 because the destination prefix changed. This introduces a
duplicate audit event across the clone/move case the PR is otherwise making
portable. Dedup against a portable scope/relative destination, with a
backward-compatible match for existing rows.

6. [P2] The documented re-capture remedy does not repair a row

core/tools/aidlc-onboard.ts:277-300

For an existing sha, capture updates only source_path and captured_at; it
does not restore the captured bytes or canonicalize captured_file. I deleted a
captured file, re-ran the exact capture command (exit 0), and classify still
failed captured file missing on disk. This also means re-capture cannot repair
the tampered/missing-row cases whose errors now prescribe re-capture.

Verification run: t248 40/40, t240 4/4, and
bun scripts/package.ts --check all pass. Separately, git diff --check 207db2ea..HEAD fails on trailing whitespace in
docs/reference/06-hooks-and-tools.md:539.

@alokgp
alokgp force-pushed the onboard-s1-rule-route branch from a40892b to ab516d8 Compare July 31, 2026 00:59
alokgp pushed a commit to alokgp/aidlc-workflows that referenced this pull request Jul 31, 2026
Addresses PR awslabs#660 review findings from apackeer (4 P1 / 5 P2 / ~11 P3) and
leandrodamascena (CHANGES_REQUESTED, 3 further blockers). Every finding was
reproduced against the shipped dist tools and is now covered by a test, so the
class is closed rather than the instance. No new mechanism is introduced — the
fixes tighten the stage-optional persist core and the capture ledger the design
already specifies.

Security and correctness:

* Document-derived rule text no longer rides a shell command line. A harness's
  shell expands $() and backticks in a --text argument before the tool starts,
  so no tool-side validation can defend it; persist-rule gains --text-file
  <path> so only a path reaches the command line and the untrusted bytes arrive
  through a file read, where they are inert. --text remains for caller-authored
  text. Trailing newlines in the file are stripped; an interior newline is still
  rejected, since a rule is one practice line.
* The capture manifest is committed, so it is untrusted input. classify now
  resolves each row, requires containment under onboard/files/ both before and
  after symlink resolution, and verifies the bytes' sha256 against the row —
  closing an arbitrary-local-file read via a tampered manifest, an in-tree
  symlink escape, and one row impersonating another's bytes.
* A rule persisted before any workflow stays deduplicated once the first intent
  exists: readPreWorkflowAuditSurface reads the union of the space-level and
  active-intent audit buckets, so a replayed candidate id no longer re-emits a
  duplicate RULE_LEARNED.
* Manifest read-modify-write runs inside withAuditLock, re-reading in the lock
  body; atomic writes use per-process temp names so concurrent writers cannot
  remove each other's scratch file.
* The ledger stores captured paths relative to the onboard dir, so a clone,
  move, or worktree at another path resolves the same rows.

Classification and input handling:

* The preventative prefilter is recall-biased (two distinct imperatives OR one
  repeated three times) and the skill re-judges both text dispositions, so a
  genuine standard written with one repeated imperative still reaches the human
  gate. Unreachable regex alternations removed.
* The binary sniff scans the whole buffer for a NUL rather than the first 8KiB,
  and quarantines high-byte blobs by replacement-character ratio.
* classify caps the content it returns and sets truncated: true.
* Candidate ids are per rule, not per file, and the reply carries
  already_present so an idempotent re-run is distinguishable from a fresh write.
* --space must be a bare slug; persist-rule rejects --space rather than
  ignoring it; rule text rejects newlines and the cid marker syntax.
* The directory walk uses lstatSync, skips symlinks, and prunes aidlc/ so
  capturing a project root cannot re-ingest the ledger.

Coverage and docs:

* t248 grows to 39 tests with an adversarial block (tampered manifest, shell
  metacharacters via --text-file, deep NUL, lifecycle-boundary replay, parallel
  survival); 98/98 learning-loop tests still pass, so the writeRulePractice
  refactor is non-regressive.
* Routed docs, the agent-facing inventory, the audit-format and state-machine
  emitter lists, and all five gitignore committed-ledgers updated; the
  integration test moves off its t239 collision to t240.
@alokgp

alokgp commented Jul 31, 2026

Copy link
Copy Markdown
Author

Fixed at head ab516d89 (2.5.32, rebased onto d0cd10a6). All six round-3 findings are closed,
each attacked at the boundary rather than at the reported payload, and each pinned by a test that goes
red on the old behaviour. No new mechanism — one added flag, --source-file, for the same reason
--text-file exists.

@apackeer — the round-2 → round-3 pattern was mine: every fix closed the demonstrated case and left
the boundary open. Below is the boundary each one now sits on.

1. [P1] Fixed: containment is anchored above onboard/files, not on it

core/tools/aidlc-onboard.ts:177-233 (chain starts :210)

realpath(filesRoot) as the anchor was circular. The chain is now verified downward from the one
directory ledger content cannot forge: realpath(projectDir)realpath(onboardDir)
realpath(filesRoot) → the row's resolved path, each required to be a real descendant of the
previous. A symlinked files/ now fails at the second link, and a symlinked onboard/ at the first.

Attacked all three levels plus the ancestor, against the shipped dist tool:

files/<symlink out>            -> exit 1  "resolves outside the onboard files dir via a link"
onboard/files -> /outside      -> exit 1  "the onboard files dir resolves outside the onboard dir"
onboard -> /outside            -> exit 1  "the onboard dir resolves outside the project"

No false positive when projectDir is itself a symlink (the macOS /tmp/private/tmp case).
t248 covers the two new levels.

2. [P1] Fixed: paths do not reach a shell either

core/tools/aidlc-learnings.ts:799-810 (readValueFile), :823 (--source-file), core/skills/aidlc-onboard/SKILL.md

Correct that --text-file only fixed half of it, and that quoting was never the answer — a POSIX
filename may contain ' and close the quote itself. persist-rule gains --source-file <path>,
so the ledger-derived source_path travels by file exactly as the text does. SKILL.md no longer
frames single-quoting as a safeguard; the rule is now "never interpolate a value you did not type
yourself", with Step 1's human-supplied --source named as the sole exception.

Verified: a file named policy'; touch pwned; #.md runs through capture → classify → promote with no
marker file created, and the hostile name lands verbatim in the audit row as inert provenance.

3. [P2] Fixed: --space enforces its own contract

core/tools/aidlc-onboard.ts:148-176

The flag documented "an existing space" while checking only the slug shape. Existence is now checked
against listSpaces(), which always reports default — so --space typo-space exits 2 with the
existing-space list and creates nothing, while a bare project with no --space still resolves the
default space (the t248 pin holds).

4. [P2] Fixed: no signal reads a window

core/tools/aidlc-onboard.ts:443 (hasNulByte), :474 (decodesAsUtf8)

Fixing only the NUL scan left the control-byte and replacement-character checks probing 8 KiB — the
same hole in two more places. All signals now read the whole buffer, and the U+FFFD ratio is
replaced by a fatal TextDecoder: simpler, stricter, and with no threshold to tune.

9000 ASCII + 50000 0xff   -> unsupported-binary   (was other-text, 50k replacement chars in content)
9000 ASCII + 50000 0x01   -> unsupported-binary
9000 ASCII + 500 NUL      -> unsupported-binary
accented UTF-8 prose      -> preventative         (no false positive)

The trade-off is deliberate and now stated where users will see it: a latin-1 / Windows-1252 document
classifies unsupported-binary rather than decoding to mojibake. SKILL.md tells the model to say so
and ask for a UTF-8 copy; 12-cli-commands.md and 06-hooks-and-tools.md both record it.

5. [P2] Fixed: the dedup key is portable, and the legacy match is canonical

core/tools/aidlc-learnings.ts:399-419 (legacyDestinationMatches), :421-441 (canonicalPath), :442-449 (auditDestination)

Destination is now project-relative — which the committed fixture
tests/fixtures/v05-mr12-learnings/audit-learnings-captured.md:25 already used, so this is the
established shape rather than a new one.

Worth flagging that my first version of this fix was still wrong, and an internal review pass
caught it before this push: the legacy fallback compared the stored absolute path as a raw string,
so the same project reached through a symlink or a trailing slash failed to match and re-emitted —
rule_learned: 1 alongside already_present: true, the same contradiction shape one layer out.
resolveProjectDir never canonicalises, which is what made it reachable. Both sides are now compared
through canonicalPath(), which walks up to the deepest existing ancestor (a destination file, or its
memory/ dir, may legitimately not exist yet).

legacy absolute row, replayed via the real path   -> rule_learned 0, 1 audit row
                     via a symlinked alias        -> rule_learned 0, 1 audit row
                     via a trailing-slash form    -> rule_learned 0, 1 audit row
project -> team scope change                       -> rule_learned 1  (round-2 behaviour intact)

6. [P2] Fixed: the prescribed remedy actually repairs

core/tools/aidlc-onboard.ts:334-356

Several errors said "re-capture the source to rebuild this row" while capture only refreshed
source_path/captured_at — so those messages were false. On an existing sha, capture now rewrites
the bytes whenever what is on disk does not match the row, and canonicalises captured_file.
Verified by running the exact printed command for a deleted file, a truncated file, and a hand-edited
captured_file — all three repair, and classify succeeds afterwards.

Trailing whitespace at docs/reference/06-hooks-and-tools.md:539 is gone; git diff --check is clean.

Rebase

Rebased from 207db2ea onto d0cd10a6 (2.5.30) — 0 behind, 4 ahead. Version moved 2.5.12 →
2.5.32 (2.5.30 merged, 2.5.31 claimed by #686). Upstream's 142 CHANGELOG entries are preserved
with one added; verified by heading-set diff, not by eye.

core/tools/aidlc-lib.ts was the one file both sides changed. Our onboard helpers sit in separate
blocks from the new freeform_default / selectionAwareDefaultScope resolver (:3869-4047), and
aidlc-onboard.ts references none of those symbols — the subsystems are disjoint despite sharing the
file. Upstream's own new tests (t252, t257, t258, 43 assertions) pass on the rebased tree, which
is the real evidence the merge is sound rather than just textually clean.

Verification

t248 is now 54 tests. The new ones assert at the boundary: a symlinked files/ root, a
symlinked onboard/ ancestor, invalid UTF-8 and control bytes past the old window, an unknown
--space (and a bare project still working), a shell-metacharacter filename through --source-file,
re-capture repairing a deleted and a hand-edited row, and the legacy-alias dedup case. I confirmed
they go red on the pre-fix code by reverting the guard and re-running.

98/98 learning-loop regressions still pass, so the writeRulePractice / priorAuditRow changes are
non-regressive.

Gates run locally

bun scripts/package.ts --check OK on all 5 trees; typecheck + lint clean; smoke+unit 182 files /
4552 assertions / 0 failed
; changelog guard OK (142 preserved, 1 new); coverage registry fresh with
the ratchet held; docs zensical --strict clean; git diff --check d0cd10a6..HEAD clean.

Integration-tier failures are unchanged in character from my last comment: t66/t89/t92
pre-existing (identical on a clean base worktree), t20/t21/t25/t70 and the sdk/journey set
parallelism flakes that pass serially, and t72 non-deterministic on both trees. CI's PR gate is
--smoke --unit, so the integration tier is not a merge gate either way.

Process change on our side

Three rounds of the same shape was the signal. Two things changed:

The classes are now pinned as executable adversarial tests in the tier CI gates, not as review
notes — trust-root anchoring at every level, shell metacharacters through every documented command
shape, whole-input validation, portable identity across clone/move, tampered committed input, and
"does the remedy I printed actually work".

And we now run an independent reviewer with an information firewall before pushing, rather than
grading our own fixes. Its first run returned NON-COMPLIANT and caught the raw-string legacy match in
finding 5 — that would have been round four. Its second pass caught nothing new; a third, scoped to
the rebase, confirmed no merge damage. That gate is where this kind of finding should surface, not in
your queue.

iuryeng added a commit to iuryeng/aidlc-workflows that referenced this pull request Jul 31, 2026
v2 shipped 2.5.30 while this branch still declared 2.5.27. Scanned the
open PRs by their actual AIDLC_VERSION diff rather than their titles:
2.5.31 (awslabs#535, awslabs#661, awslabs#686), 2.5.32 (awslabs#660, awslabs#687) and 2.5.33 (awslabs#689) are
claimed, so this takes 2.5.34.

The CHANGELOG entry was rebuilt from v2's file with this branch's block
reinserted, so no upstream heading is lost. Its sensor-cache bullet now
describes the engine-path match rather than the leaf-name one, and a new
bullet covers the clean-filter binding. Coverage registry regenerated
with the tool, not hand-edited.
@apackeer

Copy link
Copy Markdown
Contributor

Re-checked head ab516d89. Most of the review findings are now closed, and the focused t248 slice passes all 54 tests, but I can still reproduce two gaps.

1. [P1] Step 1 still puts an arbitrary source path into a shell command

core/skills/aidlc-onboard/SKILL.md:78-88 still documents:

bun {{HARNESS_DIR}}/tools/aidlc-onboard.ts capture --source '<path>'

The new --source-file transport closes the Step 5 ledger-derived source_path case, but aidlc-onboard.ts:356-362 still accepts only --source for capture. A direct source path containing a single quote therefore still terminates the documented quoting.

I reproduced this at the current head with a source filename containing:

policy'; touch pwned-step1; #.md

Running the documented command shape created pwned-step1; the initial capture failed with source not found, but the injected touch ran and made the overall shell command exit 0.

The current shell-safety test does not cover this command shape: t248:918-940 covers persist-rule --source-file, while the test helper at t248:57-61 invokes tools through an argument vector and therefore bypasses shell parsing.

This needs a file/stdin transport for capture paths or an argument-vector execution mechanism. Treating a human-provided path as the exception does not make a filename containing ' shell-safe.

2. [P2] Legacy absolute audit destinations still duplicate after a real move

New audit rows are correctly project-relative. However, the requested backward-compatible match for existing absolute rows is still path-dependent: legacyDestinationMatches() at core/tools/aidlc-learnings.ts:399-408 compares canonical absolute paths for equality.

I reproduced:

  1. Persist a candidate.
  2. Rewrite its Destination into the pre-fix absolute form.
  3. Copy the project to a different path.
  4. Replay the same candidate in the copied project.

The replay returned:

{"rule_learned":1,"already_present":true}

and the copied audit contained two RULE_LEARNED rows.

The tests cover a move for a newly written relative row (t248:982-1008) and a legacy absolute row reached through a symlink alias to the same physical path (t248:1016-1052), but not a legacy absolute row after an actual copy/move.

Everything else from the latest review round appears addressed: containment is anchored above onboard/files, unknown spaces are rejected, decoding checks the full buffer, re-capture repairs rows, and the new destination format is portable.

alokgp pushed a commit to alokgp/aidlc-workflows that referenced this pull request Aug 1, 2026
Addresses PR awslabs#660 review findings from apackeer (4 P1 / 5 P2 / ~11 P3) and
leandrodamascena (CHANGES_REQUESTED, 3 further blockers). Every finding was
reproduced against the shipped dist tools and is now covered by a test, so the
class is closed rather than the instance. No new mechanism is introduced — the
fixes tighten the stage-optional persist core and the capture ledger the design
already specifies.

Security and correctness:

* Document-derived rule text no longer rides a shell command line. A harness's
  shell expands $() and backticks in a --text argument before the tool starts,
  so no tool-side validation can defend it; persist-rule gains --text-file
  <path> so only a path reaches the command line and the untrusted bytes arrive
  through a file read, where they are inert. --text remains for caller-authored
  text. Trailing newlines in the file are stripped; an interior newline is still
  rejected, since a rule is one practice line.
* The capture manifest is committed, so it is untrusted input. classify now
  resolves each row, requires containment under onboard/files/ both before and
  after symlink resolution, and verifies the bytes' sha256 against the row —
  closing an arbitrary-local-file read via a tampered manifest, an in-tree
  symlink escape, and one row impersonating another's bytes.
* A rule persisted before any workflow stays deduplicated once the first intent
  exists: readPreWorkflowAuditSurface reads the union of the space-level and
  active-intent audit buckets, so a replayed candidate id no longer re-emits a
  duplicate RULE_LEARNED.
* Manifest read-modify-write runs inside withAuditLock, re-reading in the lock
  body; atomic writes use per-process temp names so concurrent writers cannot
  remove each other's scratch file.
* The ledger stores captured paths relative to the onboard dir, so a clone,
  move, or worktree at another path resolves the same rows.

Classification and input handling:

* The preventative prefilter is recall-biased (two distinct imperatives OR one
  repeated three times) and the skill re-judges both text dispositions, so a
  genuine standard written with one repeated imperative still reaches the human
  gate. Unreachable regex alternations removed.
* The binary sniff scans the whole buffer for a NUL rather than the first 8KiB,
  and quarantines high-byte blobs by replacement-character ratio.
* classify caps the content it returns and sets truncated: true.
* Candidate ids are per rule, not per file, and the reply carries
  already_present so an idempotent re-run is distinguishable from a fresh write.
* --space must be a bare slug; persist-rule rejects --space rather than
  ignoring it; rule text rejects newlines and the cid marker syntax.
* The directory walk uses lstatSync, skips symlinks, and prunes aidlc/ so
  capturing a project root cannot re-ingest the ledger.

Coverage and docs:

* t248 grows to 39 tests with an adversarial block (tampered manifest, shell
  metacharacters via --text-file, deep NUL, lifecycle-boundary replay, parallel
  survival); 98/98 learning-loop tests still pass, so the writeRulePractice
  refactor is non-regressive.
* Routed docs, the agent-facing inventory, the audit-format and state-machine
  emitter lists, and all five gitignore committed-ledgers updated; the
  integration test moves off its t239 collision to t240.
@alokgp
alokgp force-pushed the onboard-s1-rule-route branch from ab516d8 to 429c1bd Compare August 1, 2026 04:27
@alokgp

alokgp commented Aug 1, 2026

Copy link
Copy Markdown
Author

Fixed at head fb1360a3 (2.5.35, rebased onto 57ae3d05). Both findings are closed — and rather
than patch the two flags you named, I enumerated the whole injection surface and closed it. That
turned up three more of the same class that had not been reported yet.

@apackeer — the pattern across these rounds was mine: each fix closed the flag that had been
demonstrated and left its siblings open. Below is the complete surface, and the evidence for each.

1. [P1] Fixed: no path reaches a shell, with no "trusted path" exception

core/tools/aidlc-onboard.ts:366 (readSourceFile), :555 (--id-file), core/tools/aidlc-learnings.ts:829 (readValueFile), :845 (--candidate-id-file), :869 (--heading-file)

You were right that --text-file closed only half of it, and that exempting the human-supplied path
was not a defence. The correct property is whether the value can carry a hostile byte, never who
supplied it — a POSIX filename may contain ' whoever typed it.

So instead of adding a transport for capture --source alone, I enumerated every value the skill
puts on a command line and asked of each whether a document or a committed file could influence
it. That found three surfaces beyond the one you reported:

value influenced by transport round found
persist-rule --text customer document text --text-file 2
persist-rule --source captured filename --source-file 3
capture --source human path; filename may contain ' --source-file 4 (yours)
persist-rule --heading routed from document content (Step 3) --heading-file 4 (internal)
classify --id sha256 from the committed manifest --id-file 4 (internal)
persist-rule --candidate-id built from the manifest id --candidate-id-file 4 (internal)
--scope closed 2-value enum bare, validated
--space active-space cursor; slug + existence checked bare, validated
--project-dir framework-owned bare

Only the last three remain on a command line, and each is either a closed enum or framework-owned.
SKILL.md now states the rule as never interpolate a value you did not type yourself rather than
advising quoting, and Steps 1, 2 and 5 all route through scratch files.

One correction to a claim I made earlier in this PR: a backtick in a bare flag is expanded by the
shell before the tool process starts — the tool receives the already-expanded string, so no
in-tool validation can see the payload. Validation is defence in depth for the file path; the
transport is the actual fix. Verified directly: the tool received ## Corrections while the marker
file had already been created.

Every transport is tested through a real sh -c, because a test that spawns via an argument vector
cannot observe shell injection however it is titled — my earlier "shell safety" test had exactly that
defect.

2. [P2] Fixed: the legacy dedup match survives a real copy, not just an alias

core/tools/aidlc-learnings.ts:427 (workspaceRelativeTail)

Also correct, and the diagnosis was sharper than my fix: comparing canonical absolute paths only
reconciles different spellings of one physical location. That is the symlink-alias case — which is
what I had fixed, and what I had written the test for — so a genuine cp -R to a new path, where the
old prefix exists nowhere, still duplicated.

The match is now on the workspace-relative tail (aidlc/spaces/<space>/memory/<scope>.md), which
is what is invariant under a move, anchored at the last /aidlc/ boundary so an unrelated path ending
in the same filename cannot satisfy it.

legacy absolute row, replayed after cp -R to a new path  -> rule_learned 0, 1 audit row
                     via a symlinked alias               -> rule_learned 0, 1 audit row
                     via a trailing-slash --project-dir   -> rule_learned 0, 1 audit row
project -> team scope change                              -> rule_learned 1  (unchanged)

Rebase

Rebased onto 57ae3d05 (2.5.32) — 0 behind, 4 ahead. Version moved to 2.5.35: upstream now
owns 2.5.32 and an open PR claims 2.5.34.

Upstream's two commits touch the sensor dispatcher, the linter sensor and the plugin compose hook —
no overlap with this slice's logic; the only shared files were the version/CHANGELOG/README
serialization points. Upstream's 144 CHANGELOG entries are preserved with one added, verified by
heading-set diff rather than by eye. Upstream's own new tests (t251, t262) pass on the rebased
tree, which is the real check that the merge is sound rather than merely textually clean.

Verification

t248 is now 66 tests. The shell-transport block spawns through sh -c and covers each of the
six file transports with a slash-free '; touch pwned-marker; # filename and with $(…)/backtick
payloads; the portability block covers a genuine cp -R for both a fresh row and a legacy absolute
one. I confirmed the new tests go red on the pre-fix code by reverting each guard in turn.

98/98 learning-loop regressions pass, so the writeRulePractice / priorAuditRow changes remain
non-regressive.

Gates run locally

bun scripts/package.ts --check OK on all 5 trees; typecheck + lint clean; smoke+unit 184 files /
4575 assertions / 0 failed
; changelog guard OK (144 preserved, 1 new); coverage registry fresh with
the ratchet held; docs zensical --strict clean; git diff --check 57ae3d05..HEAD clean.

Integration-tier failures are unchanged in character: t66/t89/t92 pre-existing (identical on a
clean base worktree), t20/t21/t25/t70 and the sdk/journey set parallelism flakes that pass
serially, and t72 non-deterministic on both trees. CI's PR gate is --smoke --unit, so the
integration tier is not a merge gate either way.

What changed in how we verify

Five rounds of the same shape was the signal, and the fix was not more care — it was three specific
changes:

Enumerate the surface once. When one unsafe transport is found, list every value that crosses
the same boundary and close them together. Fixing them one at a time is precisely how a two-round
review becomes five.

Match the test's transport to the threat. If the risk is a shell, the test goes through sh -c.
If the risk is a committed file, the test hand-edits that file. A test whose mechanism cannot observe
the defect is a coverage claim with nothing behind it.

Run an independent reviewer before pushing, briefed on the requirement rather than the fix. Three
of these rounds were verified internally and still shipped — twice because the reviewer was pointed at
the case I had already fixed, so it confirmed my framing instead of the property. Pointed at the
property, it found the --heading, classify --id and --candidate-id surfaces above before this
push rather than after.

@apackeer apackeer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked the actual current head 429c1bdb after the latest force-push. The two findings from my last pass are closed, and the new file transports are present, but the broader invariants still have blockers.

P1: capture follows committed symlink roots and writes outside the project

core/tools/aidlc-onboard.ts:315-320

The trust-root chain is enforced only by resolveVerifiedCapturedPath() on classify. Capture derives captured, creates its parent, and writes without proving that onboard/ or onboard/files/ stays under the project.

I replaced aidlc/spaces/default/onboard with a symlink to an external directory and ran the shipped capture tool. It exited 0 and wrote both manifest.json and the copied bytes under the external target. The new tests at t248:682-720 exercise those symlink roots only through classify.

Apply the same project -> onboard -> files realpath anchoring before every capture/repair write.

P1: manifest locking is bypassed by path aliases

core/tools/aidlc-onboard.ts:243-245, core/tools/aidlc-lib.ts:3054-3059

inManifestLock() passes the raw projectDir spelling to a lock identity that also uses the raw string. The manifest path itself is normalized by path joins, so /project and /project/ (or a symlink alias and the real path) mutate the same ledger while acquiring different locks.

Reproduced with 40 parallel captures alternating two spellings of one project: every process exited 0, but only 30 manifest rows survived. The concurrency test uses one identical spelling for all writers (t248:367-386), so it misses this. Canonicalize the workspace identity used by the lock.

P1: onboard audit dedup breaks when the active intent changes

core/tools/aidlc-lib.ts:1959-1962, :2183-2201

The prior-row lookup reads the bare space bucket plus only the currently active intent, while emission also follows the currently active intent. Persist under intent A, switch to B, and replay the same candidate: the tool returns rule_learned: 1, already_present: true and emits a second RULE_LEARNED.

The existing test covers bare-space -> first-intent only (t248:1338-1370). A space-level rule needs a stable space-level audit identity, or the lookup must cover every intent bucket in that space.

P1: ordinal candidate ids can silently suppress a different approved rule

core/skills/aidlc-onboard/SKILL.md:237-248, core/tools/aidlc-learnings.ts:566-603

The recipe <manifest-id>-<n> assigns identity from an LLM-produced ordering. On a rerun, candidates can be reordered or revised. The writer checks only whether that marker already exists, not whether its stored text matches.

Reproduced: persist docsha-1 with “All requests use TLS”, then replay docsha-1 with “All production access requires MFA”. The second call returned rule_learned: 0, already_present: true; the MFA rule was absent. Use a stable text-derived id, or hard-fail when an existing marker's text differs.

P1: arbitrary customer content is still treated as model instructions

core/tools/aidlc-onboard.ts:594-597, core/skills/aidlc-onboard/SKILL.md:149-167

The shell boundary is improved, but classify now places customer-controlled text directly in model context and the skill tells the model to read, judge, and draft from it. There is no instruction to treat embedded directives as inert data or to prohibit document-directed tool calls. The human gate occurs after this model pass, so it does not contain pre-gate prompt injection.

Add an explicit untrusted-content boundary to the skill and adversarial coverage for a document that attempts to redirect the workflow or invoke tools.

P2s

  • Re-capture cannot repair a tampered digest. Capture dedups only on row.sha256 (aidlc-onboard.ts:317), while classify tells the user to re-capture after a digest mismatch (:580-585). Changing the first row's digest then re-capturing appends a second row with the same id; classify keeps selecting the first bad row and still fails. The repair tests cover deleted bytes and captured_file, not digest tampering.
  • Legacy cross-platform destinations still duplicate. legacyDestinationMatches() rejects with host-native isAbsolute() before separator normalization (aidlc-learnings.ts:407-421). On Linux, a legacy C:\...\aidlc\spaces\default\memory\project.md row is ignored and replay emits another audit row; POSIX -> Windows has the inverse issue.
  • The new heading allowlist rejects valid Markdown headings. HEADING_REGEX (aidlc-learnings.ts:817-823) rejects ordinary existing inputs such as Security: IAM and non-ASCII headings such as Sécurité, although file transport neutralizes shell expansion and ensureHeading() already regex-escapes the value.
  • The claimed shell coverage does not run the complete documented Step 5 shape. t240 still uses argv plus bare --source, --id, --candidate-id, and --text (t240:64-90). The sh -c unit cases split the four file transports across different commands, and the hostile-id test writes a normal SHA (t248:1172-1185), so it cannot detect the injection its comment names.
  • The standalone skill does not locate its gate annex. SKILL.md:179 says to use the harness question-rendering annex without naming it, but packaging places that file under sibling skills/aidlc/question-rendering.md, not under aidlc-onboard. Reference that concrete sibling path so a direct skill load gets the harness's gate mechanism.

Current branch state

  • GitHub reports CONFLICTING/DIRTY: v2 is now two commits ahead at 6b264081 (2.5.33).
  • The latest comment names head fb1360a3, which does not resolve; the actual force-push was to 429c1bdb.
  • The PR title still says 2.5.12 while the branch ships 2.5.35.
  • After rebasing, the new t248-onboard-capture-classify and t240-onboard-skill-flow names collide numerically with current-base t248-steering-content-delivery and t240-opencode-packaging.
  • Docs still describe the removed replacement-character probe (docs/reference/06-hooks-and-tools.md:565), the recovery error still recommends unsafe capture --source '<path>' (aidlc-onboard.ts:182-189), and the 2.5.35 changelog does not list the newly added --source-file/--id-file/--candidate-id-file/--heading-file user-facing flags.

Direct shipped-tool repros were used for the behavioral findings. Current CI is green for contract checks and smoke+unit; no integration check is attached to the PR.

alokgp pushed a commit to alokgp/aidlc-workflows that referenced this pull request Aug 2, 2026
Addresses PR awslabs#660 review findings from apackeer (4 P1 / 5 P2 / ~11 P3) and
leandrodamascena (CHANGES_REQUESTED, 3 further blockers). Every finding was
reproduced against the shipped dist tools and is now covered by a test, so the
class is closed rather than the instance. No new mechanism is introduced — the
fixes tighten the stage-optional persist core and the capture ledger the design
already specifies.

Security and correctness:

* Document-derived rule text no longer rides a shell command line. A harness's
  shell expands $() and backticks in a --text argument before the tool starts,
  so no tool-side validation can defend it; persist-rule gains --text-file
  <path> so only a path reaches the command line and the untrusted bytes arrive
  through a file read, where they are inert. --text remains for caller-authored
  text. Trailing newlines in the file are stripped; an interior newline is still
  rejected, since a rule is one practice line.
* The capture manifest is committed, so it is untrusted input. classify now
  resolves each row, requires containment under onboard/files/ both before and
  after symlink resolution, and verifies the bytes' sha256 against the row —
  closing an arbitrary-local-file read via a tampered manifest, an in-tree
  symlink escape, and one row impersonating another's bytes.
* A rule persisted before any workflow stays deduplicated once the first intent
  exists: readPreWorkflowAuditSurface reads the union of the space-level and
  active-intent audit buckets, so a replayed candidate id no longer re-emits a
  duplicate RULE_LEARNED.
* Manifest read-modify-write runs inside withAuditLock, re-reading in the lock
  body; atomic writes use per-process temp names so concurrent writers cannot
  remove each other's scratch file.
* The ledger stores captured paths relative to the onboard dir, so a clone,
  move, or worktree at another path resolves the same rows.

Classification and input handling:

* The preventative prefilter is recall-biased (two distinct imperatives OR one
  repeated three times) and the skill re-judges both text dispositions, so a
  genuine standard written with one repeated imperative still reaches the human
  gate. Unreachable regex alternations removed.
* The binary sniff scans the whole buffer for a NUL rather than the first 8KiB,
  and quarantines high-byte blobs by replacement-character ratio.
* classify caps the content it returns and sets truncated: true.
* Candidate ids are per rule, not per file, and the reply carries
  already_present so an idempotent re-run is distinguishable from a fresh write.
* --space must be a bare slug; persist-rule rejects --space rather than
  ignoring it; rule text rejects newlines and the cid marker syntax.
* The directory walk uses lstatSync, skips symlinks, and prunes aidlc/ so
  capturing a project root cannot re-ingest the ledger.

Coverage and docs:

* t248 grows to 39 tests with an adversarial block (tampered manifest, shell
  metacharacters via --text-file, deep NUL, lifecycle-boundary replay, parallel
  survival); 98/98 learning-loop tests still pass, so the writeRulePractice
  refactor is non-regressive.
* Routed docs, the agent-facing inventory, the audit-format and state-machine
  emitter lists, and all five gitignore committed-ledgers updated; the
  integration test moves off its t239 collision to t240.
@alokgp
alokgp force-pushed the onboard-s1-rule-route branch from 429c1bd to 6d1075f Compare August 2, 2026 11:07
@alokgp alokgp changed the title feat(onboard): /aidlc-onboard S1 — promote core + rule route (2.5.12) feat(onboard): /aidlc-onboard S1 — promote core + rule route (2.5.36) Aug 2, 2026
@alokgp

alokgp commented Aug 2, 2026

Copy link
Copy Markdown
Author

Addressed all five P1s and all five P2s in 6d1075f0 (2.5.36, rebased onto 6b264081).
Each was reproduced against the shipped dist tools at 429c1bdb before the fix and at
this head after.

  1. Capture anchors the trust chain before it writes. assertOnboardRootTrusted now
    runs from captureOneFile and writeManifest, and verifies project → onboard → files
    before the mkdir as well as after. A symlinked onboard/ or onboard/files/ went from
    exit 0 with the manifest and bytes written outside the project, to exit 1 with the
    external directory untouched — no empty files/ footprint either.
  2. The audit lock identity is canonicalised. canonicalWorkspaceKey (realpath, falling
    back to resolve) replaces the raw projectDir string, so /p, /p/ and a symlink
    alias share one lock. 40 parallel captures alternating two spellings went from losing
    rows (28–34 of 40 across runs) to 40/40; 60/60 across four spellings.
  3. A pre-workflow rule's audit identity is space-level in both directions. The write is
    pinned to the space-level shard (preWorkflowAuditFilePath) and the read scans the space
    bucket plus every intent bucket (readPreWorkflowAuditSurface). Persisting under intent
    A and replaying under B went from rule_learned:1, already_present:true with 2
    RULE_LEARNED rows, to rule_learned:0 with 1.
  4. A candidate id reused for different text is refused, not silently dropped. The writer
    compares the stored text, not just marker presence, and exits 2 naming both sides. Your
    TLS→MFA case went from rule_learned:0, already_present:true at exit 0 with the MFA rule
    absent, to exit 2. This required moving the learned date out of the rule text into the
    marker (<!-- cid:<ns>:<id>; learned:<date> -->) so stored text is read by position
    rather than pattern-matched — three attempts to compare without that all mis-parsed rules
    whose own text ends in a parenthetical. One legacy shape is refused rather than guessed
    at: an old-format line whose trailing (learned <ISO date>) is indistinguishable from
    rule text reports "could not be read" instead of risking a drop.
  5. Customer content is framed as untrusted data on the data itself. classify emits
    content_trust: "untrusted" and a handling notice ahead of content, so the boundary
    holds on a direct tool call with no SKILL.md in the loop; the skill adds an
    untrusted-data section and a rule that document-directed text never becomes a candidate.
    This is a prompt-level mitigation and I make no bypass-proof claim — what is
    deterministic is that classify writes no rule, only the post-gate persist-rule call.
  6. Re-capture repairs a tampered digest. Capture matches id or sha256 and
    restores both, so the remedy classify prescribes now works without duplicating the row.
  7. Legacy absolute destinations dedup cross-platform. isAbsoluteCrossPlatform replaces
    host-native isAbsolute() on the raw string, so a C:\… row normalises instead of being
    discarded when the committed ledger is read on POSIX.
  8. The heading allowlist accepts ordinary headings. headingRejection replaces the ASCII
    regex: Security: IAM, Sécurité, Data & Privacy, CI/CD, Testing (CI) pass; line
    breaks, control characters, <!-- cid:, leading list markers and shell substitution are
    refused.
  9. Step 5's shell coverage runs the documented shape. t264's main flow goes through
    sh -c with every file transport, and the hostile-id test now uses a genuinely
    $()-tampered id instead of a well-formed SHA.
  10. The skill locates its gate annex. SKILL.md names ../aidlc/question-rendering.md;
    {{HARNESS_DIR}} would be wrong since codex ships these skills under .agents/.

Also from your branch-state list: the replacement-character probe sentence is gone from
docs/reference/06-hooks-and-tools.md, the recovery error no longer recommends
capture --source '<path>', the changelog lists the five new *-file flags, and the PR
title is corrected to 2.5.36.

Two things I found while verifying, neither on your list:

  • An unrelated project's audit row suppressed a real emission. Legacy-absolute
    Destination dedup can only compare the workspace-relative tail, which every workspace
    shares — so a stranger's row matched and the RULE_LEARNED for a genuinely new candidate
    was never written. priorAuditRow now reports how it matched ({found, exact, legacyOnly})
    and emission is tied to the write when only the tail heuristic matched (t263:1910).
  • rule_learned reports emission, not a rule write, and I want your call on it. When a
    hand-authored line carries a marker the ledger never recorded, persist-rule backfills the
    row and returns rule_learned:1 with already_present:true — having written no rule.
    Backfilling I believe is right (it converges: 1 row, 1 bullet, clean no-op after), but
    docs/reference/06-hooks-and-tools.md:549 documents the reply as distinguishing an
    idempotent re-run from a fresh write, which that value does not do. I added
    audit_backfilled to disambiguate rather than rename a shipped field mid-review. Either
    split the reporting so each field answers one question, or keep the flag and fix the naming
    separately — I lean the former.

Validation:

  • bash tests/run-tests.sh --smoke --unit: 0 failures. bun scripts/package.ts --check
    passed for all five harnesses. Changelog completeness and docs build passed.
  • t263-onboard-capture-classify: 90 tests (was 66), 0 failures. t264-onboard-skill-flow:
    4 passed. Learning-loop regression (t97, t99, t112, t158, t199): 0 failures.
  • Integration --integration --no-llm, 104 files: 1 failing, t92 (43 pass / 4 fail),
    identical on the pre-slice base 6b264081. On the live-model profile t192/t193/t238
    also fail — sdk live journeys that timed out after 887–1787s and vary per run.
  • Item 4's comparison was attacked with 21 input shapes; the earlier rounds' findings
    (containment, the five injection surfaces, concurrency, encoding, doc sweep) were
    re-checked rather than assumed.
  • Note on test names: mine were renumbered on the rebase — t248/t240 are now your
    t248-steering-content-delivery/t240-opencode-packaging. t263:N is a line in
    tests/unit/t263-onboard-capture-classify.test.ts.

Two disclosures: an ad-hoc verification run of mine wrote a scratch aidlc/ workspace under
dist/claude/, which failed t145-packaging-parity on byte-parity orphans — removed
(untracked only; that path also holds 9 tracked files, untouched), t145 back to 16/16, and
my verification scripts now refuse to run from inside the repo. And my earlier replies on this
PR answered a subset of findings without saying so: I had been reading only
gh pr view --json reviews, which omits the two rounds posted as issue comments, including
@leandrodamascena's round-1 review.

iuryeng added a commit to iuryeng/aidlc-workflows that referenced this pull request Aug 3, 2026
v2 shipped 2.5.30 while this branch still declared 2.5.27. Scanned the
open PRs by their actual AIDLC_VERSION diff rather than their titles:
2.5.31 (awslabs#535, awslabs#661, awslabs#686), 2.5.32 (awslabs#660, awslabs#687) and 2.5.33 (awslabs#689) are
claimed, so this takes 2.5.34.

The CHANGELOG entry was rebuilt from v2's file with this branch's block
reinserted, so no upstream heading is lost. Its sensor-cache bullet now
describes the engine-path match rather than the leaf-name one, and a new
bullet covers the clean-filter binding. Coverage registry regenerated
with the tool, not hand-edited.

@apackeer apackeer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 6d1075f0 against base 6b264081, including the latest author response. The canonical lock identity, space-level pre-workflow audit identity, whole-buffer UTF-8 checks, ordinary heading support, prompt-data framing, and gate-annex path are present. The branch still has blockers.

P1: candidate IDs are matched by prefix, so an approved rule can be silently dropped

core/tools/aidlc-learnings.ts:598-601, :711-716

Both marker lookups search for <!-- cid:<namespace>:<id> without requiring the next character to terminate the id. The documented ordinal recipe naturally creates prefix pairs such as doc-1 and doc-10.

Reproduced against the shipped tool:

  1. Persist doc-10 with text Same rule.
  2. Persist doc-1 with the same text.
  3. The second call exits 0 with rule_learned:1, already_present:true, audit_backfilled:true, but project.md still contains only the doc-10 marker.

With different text it instead falsely reports a collision with doc-10. Require an exact marker delimiter (--> or ; learned:) after the candidate id and pin both directions.

P1: every pre-upgrade learning-loop rule now fails idempotent replay

core/tools/aidlc-learnings.ts:615-629, :737-747, :877-885

The previous shipped writer generated every rule as:

- <text> (learned YYYY-MM-DD) <!-- cid:<stage>:<id> -->

The new parser treats exactly that legacy suffix as unreadable and throws. Because the ordinary learning-loop persist path now uses this shared writer too, replaying an unedited, previously persisted selection fails instead of no-oping. This is not a rare hand-edited ambiguity; it is the format every existing installation has.

I reproduced the old generated line with an identical incoming rule and received existing rule text could not be read. Use the exact prior audit row to distinguish writer-created legacy lines, migrate the annotation, or otherwise preserve replay compatibility. The current test at tests/unit/t263-onboard-capture-classify.test.ts:1857 codifies the regression rather than covering an upgrade fixture produced by the base writer.

P1: deleting a line bypasses the new candidate/text collision guard

core/tools/aidlc-learnings.ts:737-768, :779-802

Text is compared only while the marker line exists. If an exact audit row remains but its practice line was deleted, any new text under that candidate id is accepted as “recovery”; the exact old row then suppresses a new event.

Reproduced: persist reused-1 as All requests use TLS, delete its practice line, then replay reused-1 as All production access requires MFA. The second call exits 0 with rule_learned:0, already_present:false and writes the MFA rule under the TLS audit identity. Fail closed when the old text cannot be established, or record a text digest in the audit identity.

P1: the onboard trust-root chain is still incomplete

core/tools/aidlc-onboard.ts:191-244, :516-519

Two direct shipped-tool repros remain:

  • aidlc/spaces/default/onboard -> ../../../victim is accepted because victim is still under the project. capture exits 0 and writes victim/manifest.json plus victim/files/*. The intended lexical onboard/ root has been redirected even though the latest response says symlink roots are refused.
  • list never calls assertOnboardRootTrusted; an onboard/ symlink to an external directory containing a valid manifest.json exits 0 and returns that external file.

Reject symlinks in the output-root chain or require each real child to equal the expected child of its trusted real parent, and apply the same check to list.

P1: ambiguous digest repair can overwrite the wrong manifest row

core/tools/aidlc-onboard.ts:379-394, :421-431

The new findIndex(row.id === digest || row.sha256 === digest) takes the first partial match and rewrites both identity fields. With healthy rows A and B, set only A.sha256 = B.sha256, then re-capture B: the tool rewrites A into B, leaves the original B row in place, and makes A unclassifiable. I reproduced two identical B rows and no captured file with id <A>.

Prefer a healthy exact id === sha256 === digest row; reject multiple/ambiguous partial matches instead of repairing one by array order.

P1: duplicate IDs can partially commit a persist batch

core/tools/aidlc-learnings.ts:850-889

The batch reuses one stale audit snapshot, appends audit rows immediately inside each iteration, and flushes practice files only after the whole loop. Identical duplicate IDs produce two audit rows and one line. Different-text duplicates now throw on the second selection after the first audit append but before the file flush, leaving one audit row and no practice line; retry remains inconsistent.

Validate candidate-id uniqueness before writes and stage the audit/file transaction so a later selection cannot strand earlier side effects.

P2: hash-plus-basename storage breaks dedup and valid filenames

core/tools/aidlc-onboard.ts:371-435, :498-510; core/tools/aidlc-lib.ts:1048-1049, :3417-3419

Capturing identical bytes under one.md and then two.md leaves two committed byte copies while the sole manifest row points only at two.md. In a directory capture, both output entries are the same mutable row and both report the last source. A valid 198-byte ext4 basename also fails ENAMETOOLONG because the 64-byte digest and atomic temp suffix are prepended/appended.

A hash-only storage leaf avoids all three problems; preserve provenance separately.

P2: shifted PDF headers bypass the binary quarantine

core/tools/aidlc-onboard.ts:540-552

Magic matching only checks offset zero. A file recognized by file as PDF 1.4 after one leading newline classified preventative and returned the PDF body as model content. PDF readers permit a header within their initial search window; search that window for %PDF- while keeping fixed-offset matching for formats that require it.

P2: validation occurs before normalization

core/tools/aidlc-learnings.ts:529-532, :1065-1089, :1152-1173

--text-file containing spaces only succeeds and writes an empty - <!-- cid:... --> rule, which then cannot replay. --heading '###' also passes validation, normalizes to ## , and creates an empty heading. Validate the normalized text and heading before writing.

P2: committed provenance remains machine-local and outside the trust framing

core/tools/aidlc-onboard.ts:398-405, :505-519; core/skills/aidlc-onboard/SKILL.md:112-125, :149-176, :292-295

source_path is still an absolute local path in the committed manifest and is copied into the committed audit. This can expose usernames, customer names, and private directory structure. It is also attacker-controlled metadata consumed from capture/list, while the model-facing untrusted-data boundary is scoped only to content; a hostile filename therefore arrives outside that declaration.

Store portable/sanitized provenance and explicitly treat all manifest metadata as untrusted data.

P2: the reply contract and claimed integration coverage remain contradictory

core/tools/aidlc-learnings.ts:1237-1253; core/skills/aidlc-onboard/SKILL.md:306-310; tests/integration/t264-onboard-skill-flow.test.ts:94-138

rule_learned counts audit emission, not a rule write: a backfill is 1/true/true, while recovery is 0/false/false. The skill still says rule_learned:1 means a fresh write and does not mention audit_backfilled. Keep the existing event-count meaning, but add an explicit rule_written field and document all states.

The t264 “complete documented shape” omits Step 5's required --source-file; it also models the gate by directly calling or omitting the writer, so it does not exercise annex rendering or turn termination. The unit transport tests are useful, but this integration test does not establish the claim made in its comment or the latest response.

P3: user documentation still describes earlier API shapes

  • docs/reference/06-hooks-and-tools.md:549 omits audit_backfilled.
  • docs/reference/06-hooks-and-tools.md:553 still publishes the removed ASCII heading regex.
  • docs/guide/12-cli-commands.md:678-682 omits --candidate-id-file from the all-file promotion recipe and then refers to --candidate-id.

Verification

STAMP:    tests/logs/2026-08-03T20-07-39Z
TRACES:   /home/ubuntu/src/aidlc-workflows/tmp/pr-660-rereview/tests/logs/2026-08-03T20-07-39Z/*.log  (2 files; 0 ndjson)
SUMMARY:  tests/logs/2026-08-03T20-07-39Z/summary.txt  +  failures.txt — Result: PASS; Failed files: 0
RESULT:   unit+integration . 2 pass/0 fail . reds: none . live vars set: none . invariant grep hits: 0 (path-excluded)

Focused result: t263 90/90 and t264 4/4. bun scripts/package.ts --check, bun run typecheck, bun run check, and git diff --check also pass. Direct shipped-tool probes reproduced every behavioral finding above.

The branch is also three commits behind current v2, GitHub reports CONFLICTING, and both current v2 and this PR claim 2.5.36; rebase, re-bump, and regenerate after the behavioral fixes.

Verdict: changes requested.

@apackeer

apackeer commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@leandrodamascena, could you please take another review pass on the current head? The implementation has changed substantially since your first review, and I would value your independent assessment of the remaining findings and merge readiness.

panaalok added 2 commits August 4, 2026 17:12
…(2.5.40)

Six P1s and five P2s from the round-7 review, plus the routed docs. Three of
the P1s were guards written in earlier rounds that a sibling input walked
around, so each fix targets the boundary rather than the reported payload.

* A dedup marker lookup now requires an exact delimiter after the candidate
  id, so `doc-1` no longer matches `doc-10`. The documented id recipe is an
  ordinal, which generates exactly those prefix pairs; the prefix match
  reported success while never writing the approved rule. Pinned in both
  directions.
* A practice line written by an earlier release replays as a no-op again.
  The learned date moved into the dedup marker, and the new parser was
  treating the previous format — the one every installed copy has on disk —
  as unreadable, which broke idempotent replay for the ordinary learning
  loop as much as for onboard. An exact prior RULE_LEARNED row is the
  evidence that this tool wrote the line, so its trailing date is stripped
  rather than refused; the genuinely undecidable case still refuses.
* A deleted practice line no longer lets different text occupy its
  candidate id. The text comparison ran only while the line existed, so
  deleting it let any new rule be written under the old audit identity with
  no event; the row now carries a text digest. The legitimate same-text
  recovery still rewrites the line without a second row.
* The onboard trust root is anchored by identity, not containment: no path
  component under `onboard/` may be a symlink, so a redirect whose target
  is still inside the project is refused. `list` is anchored too — it
  previously performed no check at all.
* An ambiguous manifest digest repair no longer rewrites an unrelated row.
  A partial match taken by array order could overwrite a healthy row and
  leave the original unclassifiable; a healthy exact match now wins and an
  ambiguous one is refused.
* A persist batch commits every practice line and every audit row together
  or not at all. Duplicate ids are rejected before any write, and a later
  selection's refusal can no longer strand an earlier selection's audit row
  with no matching file write.
* The captured-file storage leaf is content-addressed only. The hash plus
  basename left orphaned duplicate copies for identical bytes, shared one
  mutable row across a directory capture, and overflowed ENAMETOOLONG on a
  legal long basename.
* A PDF whose header sits past offset zero is quarantined instead of
  classified as a standard with its body returned as model content. Only
  the PDF magic is window-searched; formats that require a fixed offset
  keep it.
* Rule text and headings are validated on the normalized value that is
  actually written, so whitespace-only text and a heading of bare hashes
  are refused rather than stored as an empty rule or an empty heading.
* Committed provenance is portable: an in-project source records a
  project-relative `source_path` instead of an absolute machine-local path,
  and the untrusted-data declaration now covers every manifest field rather
  than only the document body.
* The reply separates the two questions it was conflating: `rule_learned`
  counts the audit event and `rule_written` counts the practice-line write.
  Every state — fresh write, no-op, recovery, backfill, collision — is
  documented in the skill and the reference.
@alokgp
alokgp force-pushed the onboard-s1-rule-route branch from 6d1075f to fedcd6b Compare August 4, 2026 07:19
@alokgp alokgp changed the title feat(onboard): /aidlc-onboard S1 — promote core + rule route (2.5.36) feat(onboard): /aidlc-onboard S1 — promote core + rule route (2.5.40) Aug 4, 2026
@alokgp

alokgp commented Aug 4, 2026

Copy link
Copy Markdown
Author

Addressed all six P1s and all five P2s in fedcd6bc (2.5.40, rebased onto 8b20390b).
Each was reproduced against the shipped dist tools before the fix and re-attacked after.
Version re-slotted to 2.5.40: 2.5.37 turned out to be claimed by three other unmerged
branches, and 2.5.38/39 were taken too. PR title updated; GitHub now reports MERGEABLE.

Below: the twelve round-7 items, one disclosure, then a coverage matrix over every
finding from every round
— the part I would most like your eyes on.

  1. A candidate-id marker match is exactly delimited. markerLineRegex requires [;\s]
    after the id (aidlc-learnings.ts:651), so the ordinal recipe's own doc-1/doc-10
    pairs no longer collide. Your case went from rule_learned:1, already_present:true, audit_backfilled:true with only the doc-10 marker on disk, to both rules written.
    Pinned in both directions (t263:2543, :2568).
  2. A pre-upgrade practice line replays as a no-op again. The exact prior RULE_LEARNED
    row is the evidence the line alone lacks: it proves this writer produced the line, so a
    trailing (learned <date>) is stripped rather than refused (:679, :713). Your repro
    went from existing rule text could not be read to rule_learned:0 with the file
    byte-identical. The fixture is generated by the base writer itself — git archive 6b264081 core/tools into a scratch tree, then that binary writes the legacy line — so
    the test exercises what an installation actually has on disk rather than my imitation of
    it (t263:2593). The test that codified the refusal is gone.
  3. A deleted line no longer lets different text occupy its id. The row now carries
    Text-Digest (sha256Hex, :423), so an exact row whose digest does not match fails
    closed (:912). Your reused-1 TLS→MFA case went from exit 0 with rule_learned:0, already_present:false and the MFA rule written, to exit 2 with nothing written
    (t263:2643). Same-text recovery still rewrites the line with no second row (t97, 25
    passed). One case is narrower than that — see the disclosure below.
  4. The trust root is anchored by identity, not containment. assertNoSymlinkInChain
    (aidlc-onboard.ts:228) rejects a symlink at any component under onboard/, so a
    redirect whose target is still inside the project is refused — onboard -> ../../../victim
    went from exit 0 with victim/manifest.json and victim/files/* written, to exit 1 with
    victim/ untouched. list is anchored through the same readManifest (:130); all
    three subcommands (capture, list, classify) are the complete set and each routes
    through it (t263:890, :914).
  5. An ambiguous digest repair refuses instead of guessing. A healthy exact
    id === sha256 === digest row always wins, and multiple partial matches with no healthy
    exact row are refused naming the ids (:479-519). Your A/B case went from A rewritten
    into B with two identical B rows and no captured file with id <A>, to A keeping its own
    identity and classify A reporting an honest digest mismatch (t263:2676).
  6. A persist batch is atomic. writeRulePractice returns auditFields instead of
    appending them (:1017); handlePersist accumulates pendingAuditFields and commits via
    commitRuleAudit (:1024) only after every selection validated and every file flushed.
    Duplicate ids are rejected pre-write. The route your finding did not name also holds: a
    selection colliding with a pre-existing row now leaves the earlier valid selection with
    0 lines and 0 rows, where it previously stranded 1 row and 0 lines (t263:2468, :2505,
    :2521).
  7. The storage leaf is content-addressed only. onboardRelativeCapturedFile(sha256)
    drops the basename (aidlc-lib.ts:1067). Identical bytes under two names went from two
    committed copies with one row pointing at the second, to one copy and one row; a 198-byte
    basename went from ENAMETOOLONG to captured; a directory capture reports a distinct
    source_path per entry (t263:2280, :2312).
  8. A shifted PDF header is quarantined. hasPdfMagicInWindow searches the first 1024
    bytes for %PDF- (:704-707) — PDF only. Your leading-newline PDF went from
    preventative with the body returned as content, to unsupported-binary. Every other
    magic stays offset-0: a text file mentioning PK\x03\x04 still classifies as text, a real
    zip does not (t263:290, :316).
  9. Validation runs on the normalized value. normalizeHeadingBody (:566) is shared by
    the validator and the writer, so they cannot drift. A whitespace-only --text-file went
    from exit 0 writing - <!-- cid:… --> to Invalid rule text: must not be empty or whitespace-only; --heading '###' went from heading:"## " to a refusal (t263:2397,
    :2422).
  10. Committed provenance is portable and the untrusted framing covers the whole row.
    portableSourcePath (:444) records a project-relative source_path for an in-project
    source; the committed audit's Source follows it (**Source**: docs/policy.md), and
    grepping the committed onboard/ + audit/ for the project path returns nothing. A
    source genuinely outside the project stays absolute. The untrusted-data declaration now
    names every manifest field rather than content alone, in the reply and in SKILL.md
    (t263:2358, :2375).
  11. rule_learned and rule_written answer the two different questions. Kept the
    event-count meaning and added rule_written (:1566), as you specified. Fresh write is
    1/1, replay 0/0, backfill 1/0, recovery 0/1; all five states including
    audit_backfilled are tabulated in SKILL.md Step 5 and
    docs/reference/06-hooks-and-tools.md:549. t264 now passes all four file-transported
    values including --source-file, asserts rule_written, and its comment states what a
    cli-mechanism test can and cannot establish instead of claiming annex rendering and
    turn termination it never exercised.
  12. P3 docs. 06-hooks-and-tools.md:549 names the current reply fields; the removed ASCII
    heading regex is gone, replaced by the actual denylist; 12-cli-commands.md:774,778 carry
    --candidate-id-file. Swept docs/ for other stale persist-rule/onboard prose.

No new mechanism was introduced — every fix is inside the existing writer, anchor, and
manifest surfaces.

One disclosure, since it narrows item 3 rather than completing it. The Text-Digest
guard keys on an exact Destination match. A row written by the previous build carries an
absolute Destination, which can only match through the legacy-tail heuristic
(legacyDestinationMatches, :437) and so never reaches the fail-closed branch: on such a
row, deleting the line and replaying different text still writes. It differs from the defect
you reported in that the write emits its own RULE_LEARNED carrying a digest
(rule_learned:1), so the rule is traceable rather than silent, and the exposure is one write
per pre-upgrade candidate id — replaying a third text is then refused. I left it rather than
gating legacyOnly on the digest, because that path is deliberately permissive: it is the
round-6 fix for an unrelated project's row suppressing a genuinely new rule, and every
workspace shares the tail aidlc/spaces/<space>/memory/<scope>.md. Closing one reopens the
other for absolute-destination rows, so it seems a call for you rather than one for me to make
quietly.

One test defect CI found after I posted

t263's pre-upgrade fixture extracts the real base writer with git archive 6b264081.
I verified that commit is reachable from v2 — but CI checks out with fetch-depth: 1,
where a reachable commit is still absent, so the archive failed with fatal: not a valid object name. Reachability was the wrong property; presence in a shallow fetch is the one
that matters. Fixed in 4dcf8631: the commit is fetched on demand (targeted, then
--unshallow) before archiving, and if it cannot be fetched the test skips with a
warning rather than falling back to a hand-typed legacy line
— the fixture's whole value
is that a real pre-upgrade writer produced the format, and an imitation would look like
coverage while asserting only my assumption about it. Verified by cloning --depth 1,
running the suite, and re-checking the object afterwards to confirm the fetch deepened the
clone and the test ran for real rather than skipping.

Coverage over every round, not just this one

You have raised findings across five rounds and @leandrodamascena a sixth. Fixing the
latest round says nothing about whether round 1 is still pinned, so I enumerated all
three endpoints (pulls/660/reviews, issues/660/comments, pulls/660/comments — the
last is empty) and classified every finding against the current tree. 79 findings.

Round Head Findings PINNED Fixed, unasserted Live/process Obsolete
R1 apackeer 37f675f6 23 15 5 0 3
leandrodamascena 37f675f6 19 16 3 0 0
R2 apackeer a40892b9 7 6 0 0 1
R3 apackeer 429c1bdb 16 9 1 2 4
R5 apackeer ab516d89 2 2 0 0 0
R7 apackeer 6d1075f0 12 12 0 0 0
Total 79 60 9 2 8

PINNED means a named test goes RED when the guard is reverted — not that a nearby
happy-path test exists. Every P1 and P2 across all six rounds is pinned. The nine gaps
were all P3 or sub-clause items, and six were mechanically closable, so I closed them:

Gap, and the round that raised it New test RED when reverted
Writers shared one manifest.json.tmp (@leandrodamascena §3) t263 "concurrent atomic writes to ONE destination…" 1 — and the three existing parallel tests stayed green, which is the point: they pin the lock, never the tmp name
classify must return content for other-text (§6 / R1 P1) t263 "an other-text item still carries its content…" 1 — every existing disposition assertion stayed green
Dangling symlink in a walked dir (R1 P3) t263 "a DANGLING symlink in the walked dir…" 2 — with the existing loop test
Move the project, re-run classify (§ closing list) t263 "classify still resolves…after the whole project MOVES" 1
onboarding.md never names the skill (R1 P3) t151 test 4 1
Gate-annex sibling path (R3 P2) t123 "…gate-annex reference resolves in the shipped layout" 5, one per harness

Two notes on how those are written, since both could have been done wrong:

  • t151 derives the expected skill list from core/skills/ rather than a literal array,
    so a newly added standalone skill cannot land while the onboarding doc stays behind. It
    fires on the session-skills line and the onboard bullet independently.
  • t123 resolves the annex reference from the referring file's own directory instead
    of matching a string. codex emits skills under .agents/skills/ while the other four
    use <harness-dir>/skills/, so a harness-shaped literal would have been wrong on codex
    specifically — I checked the wrong root first and nearly filed a false finding against
    my own fix.

Three gaps remain open and I am not claiming otherwise. All prose: the skill's
instruction to re-judge an other-text item (an LLM behaviour — its mechanical half is
now pinned by the test above), the four-file doc sweep, and the rollup-visibility note.
No test can assert the first; the other two are prose I would rather leave visible than
gate with a grep that passes for the wrong reason.

Validation:

  • t263-onboard-capture-classify: 114 tests (was 90 at round 6, 110 at round 7), 0
    failures, 460 assertions. t151: 4. t123: 610. t264-onboard-skill-flow: 4. t97:
    25. t28 (74/74) and t68 unchanged.
  • bash tests/run-tests.sh --smoke --unit: 188 files, all green in CI. Locally
    t248-codekb-scope-diff fails 2 assertions and t92 fails 43/4 — both reproduce with
    identical failing sets on a detached worktree at pristine 8b20390b, and both pass in
    CI, so they are environment-specific to my machine rather than standing base reds. I
    originally reported t248 here as a pre-existing failure worth a separate look; that was
    wrong in the direction that flattered this branch, and CI corrected it. A run that
    reported three failing files did not reproduce; that was worker contention.
  • bun scripts/package.ts --check (all five harnesses), bun run typecheck, bun run check, bun tests/gen-coverage-registry.ts --check (ratchet held), git diff --check:
    all clean. Changelog completeness and the zensical --strict docs build pass.
  • Each fix was verified RED before GREEN by disabling the guard in
    dist/claude/.claude/tools/ — the tests spawn the generated tool, so reverting only
    core/ is a false pass I hit once and corrected.
  • Beyond the reported items I re-attacked the earlier rounds' classes against the
    rewritten storage and provenance code: --space traversal on all three subcommands, a
    tampered captured_file (three payloads, canary never in stdout), and a hostile
    filename $(touch …)'pol.md through sh -c at both hops, which round-trips as literal
    text into the audit. No regressions.
  • Integration tier (bash tests/run-tests.sh --integration --no-llm): 104 files,
    1349 assertions, 0 failures
    . t264-onboard-skill-flow passes here, in the tier it
    belongs to. An earlier parallel run showed t99/t163/t188 failing; each passes
    standalone and the serial run is clean, so that was worker contention.
  • Not verified: the 24 live-model files that --no-llm gates closed (they need a Claude
    substrate). I checked whether any of them cover this surface — none do; the one that
    greps for "onboard" matches the word inside an intent label
    (t-journey-workspace.sdk.test.ts:411), not the /aidlc-onboard route. So the
    unverified set does not overlap the change.

The legacy-replay test extracts the real pre-upgrade writer with
`git archive 6b26408`, which passed locally and failed in CI with
"fatal: not a valid object name". CI clones with fetch-depth 1, so a
commit reachable from v2 in a full clone is simply absent there —
reachability was verified, presence was not.

The commit is now fetched on demand (targeted fetch, then --unshallow)
before the archive. If it cannot be fetched at all the test skips with a
warning rather than falling back to a hand-typed legacy line: the whole
point of the fixture is that a REAL pre-upgrade writer produced the
format, and an imitation would look like coverage while asserting my
assumption about the old format instead.

Reproduced by cloning --depth 1 and running the suite: the base commit
was absent, the fetch deepened it, and the test then ran for real
(verified by re-checking the object afterwards) — 114 pass.
@alokgp

alokgp commented Aug 4, 2026

Copy link
Copy Markdown
Author

Folded both corrections into the main reply above (edited in place) rather than leaving them stranded in a follow-up:

  • The shallow-clone fixture defect CI found, fixed in 4dcf8631, is now its own section there.
  • The t248 claim is corrected in the Validation block: it and t92 fail only on my machine and both pass in CI, so neither is a standing base red. My original "worth a separate look" framing was wrong.

The Validation block also now carries the integration tier it was missing: --integration --no-llm, 104 files / 1349 assertions / 0 failures, with t264-onboard-skill-flow passing in its own tier. All five checks green.

@leandrodamascena leandrodamascena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest head fixes the previously reported arbitrary captured_path traversal, shell interpolation in the documented skill flow, and ordinary concurrent manifest RMW race. I still found the following blockers.

  1. High: The workflow does not pin the selected space.
    core/tools/aidlc-onboard.ts:617-635, core/tools/aidlc-learnings.ts:1495-1518, core/skills/aidlc-onboard/SKILL.md:309-311

    When --space is omitted, space remains undefined, causing each path helper to reread aidlc/active-space. The human approval gate creates a long pause between capture and promotion, and another session can switch the active space during that pause. Material captured in space A can therefore be promoted into space B. A concurrent switch during persist-rule can also make the practice file and RULE_LEARNED event resolve against different spaces.

    Resolve the space once at capture time, return it in the command output, carry it through classification and the human gate, and allow persist-rule to receive that validated explicit space.

  2. High: Practice and audit writes are not transactional.
    core/tools/aidlc-learnings.ts:1507-1519, core/tools/aidlc-audit.ts:328-341

    persist-rule writes the practice file first and appends the audit event afterward. If audit directory creation or append fails, the command exits nonzero but leaves a live rule with no RULE_LEARNED record. For example, making aidlc/spaces/default/intents a regular file allows the memory write to succeed but makes creation of intents/audit fail with ENOTDIR.

    This contradicts the “together or not at all” guarantee in CHANGELOG.md:10,22 and leaves a state not represented by the result table in SKILL.md:330-338. Use a recoverable staged transaction or roll back the practice write if audit commit fails.

  3. High: Explicit non-regular sources can block indefinitely or exhaust memory while holding the workspace lock.
    core/tools/aidlc-onboard.ts:619-623, :631-635, :455-462

    Directory walking includes only regular files, but an explicit source rejects only directories and symlinks. FIFOs, character devices and other special files reach readFileSync. A FIFO with no writer blocks forever, while a source such as /dev/zero can exhaust memory.

    Because this read happens inside withAuditLock, the command also blocks unrelated AIDLC workspace mutations. Reject explicit sources unless lstatSync(...).isFile() is true.

  4. High: A long capture can have its live lock stolen and lose manifest updates.
    core/tools/aidlc-onboard.ts:351-354, :631-635, core/tools/aidlc-lib.ts:3520-3524, :3715-3718

    Capture reads every source file while holding the workspace audit lock, with no source-size limit. The default lock policy permits another process to reap a still-live owner once its stamp is older than ten minutes. A sufficiently large or slow capture can therefore lose the lock while still running.

    The second process then reads and commits the manifest independently; when the first process finishes, it can overwrite that update from its stale snapshot. Use the non-reapable-live-owner lock mode for this operation or move bounded source reads outside the manifest RMW critical section.

  5. High: Source validation still has a TOCTOU arbitrary-file-read race.
    core/tools/aidlc-onboard.ts:389-401, :619-623, :455-462

    Sources are inspected with lstatSync, but later reopened by pathname with readFileSync, which follows symlinks. In a writable source directory, another process can replace a validated regular file with a symlink between the walk and the read. A winning race captures any local file readable by the AIDLC process.

    Open each source once using no-follow semantics, validate the opened descriptor as a regular file, and read from that same descriptor.

  6. Medium: A unique partial digest match can destructively replace an unrelated manifest row.
    core/tools/aidlc-onboard.ts:504-526, :554-564

    If no healthy exact match exists, any single row matching either id or sha256 is treated as the row to repair. Consider a captured row A whose sha256 is edited to the digest of a not-yet-captured file B. Capturing B finds A as the only partial match and rewrites A’s ID, digest, path and provenance into B, permanently removing A’s ledger entry.

    A partial match does not establish which identity is authoritative. Refuse ambiguous identity disagreement unless the repair can be tied to the original source or another independently verified field.

  7. Medium: The committed, untrusted manifest is not schema-validated.
    core/tools/aidlc-onboard.ts:130-147, :842

    readManifest verifies only that the parsed value is an object containing a files array. It accepts unsupported schema versions, duplicate IDs, malformed digests, invalid dispositions and incorrectly typed rows. Capture can silently rewrite a future-schema manifest, while duplicate IDs make all but the first row unreachable through classify.

    Validate schema_version === 1, every field’s type and allowed value, digest/ID shape and uniqueness before any read or mutation.

  8. Medium: Recovery of pre-upgrade rows is no longer supported.
    core/tools/aidlc-learnings.ts:912-940, tests/unit/t263-onboard-capture-classify.test.ts:2613-2664

    A pre-upgrade RULE_LEARNED row has no Text-Digest. If its practice line is missing, replaying the original selection now fails because the tool cannot confirm the incoming text. Previous behavior restored the missing line, so this is a compatibility change to the existing persist command.

    The legacy test covers only the case where the old practice line is still present. It also silently returns without assertions when the historical commit cannot be fetched. Add a committed legacy fixture and either provide an audited migration/recovery route or document this as a breaking recovery change.

  9. Medium: The changelog gives incorrect upgrade guidance.
    CHANGELOG.md:6

    The entry says “no upgrade action required,” but existing installations do not contain the new skill, tool or harness projections. Users must re-copy or upgrade their dist/<harness>/ installation before /aidlc-onboard can exist. The changelog should state that explicitly.

  10. Low: Documentation disagrees with the implementation.
    docs/guide/03-spaces-and-intents.md:57, core/knowledge/aidlc-shared/audit-format.md:194

    The workspace diagram still says captured files use <sha256>-<filename>, while the implementation uses hash-only files/<sha256>. The canonical RULE_LEARNED schema also omits the newly emitted Text-Digest field.

Verification

bun run check passed, including packaging parity, typecheck and lint. The focused onboard tests passed 118/118, and the additional affected suites passed 752/752.

The full no-LLM CI profile reported failures in t248 and t255; both reproduce against the current origin/v2 base and do not appear introduced by this PR. Parallel-only integration timeouts passed when rerun serially.

The PR is also currently CONFLICTING and one commit behind v2, so it must be rebased and generated distributions regenerated before merge.

@apackeer

apackeer commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Proposed user experience

Users organize original documents however they want beneath:

aidlc/spaces/<space>/knowledge/documents/

They then run:

/aidlc knowledge onboard [path] [--intent [slug]]

With no path, the command scans knowledge/documents/. Files already there are indexed in place; external paths are copied there first. Documents are space-wide by default. --intent uses the active intent, while --intent <slug> uses a named existing intent. /aidlc knowledge sync refreshes changed, moved, or removed documents.

Storage model

aidlc/spaces/<space>/
├── knowledge/
│   ├── documents/           # user-managed originals
│   └── documentkb/          # tool-managed searchable content
│       ├── index.json
│       └── <document-id>/
│           ├── content.md
│           ├── summary.md
│           └── metadata.json
└── intents/
    ├── active-intent        # per-user cursor
    ├── intents.json         # committed intent registry
    └── <intent-dir>/

A compact index entry would look like:

{
  "id": "<stable-document-uuid>",
  "path": "documents/security/policy.pdf",
  "sha256": "<content-digest>",
  "related_intent_ids": ["<intent-uuid>"],
  "content": "documentkb/<id>/content.md",
  "summary": "documentkb/<id>/summary.md"
}

related_intent_ids is omitted for space-wide documents. If present, it contains canonical intent UUIDs from intents.json; an empty list is invalid. Commands may accept human-readable slugs, but the persisted association must use UUIDs.

The original remains the authoritative human-readable reference. Document identity remains stable across edits and moves; SHA-256 identifies the current revision.

For --intent, the tool reads active-intent, resolves its directory against intents.json, and persists the matching UUID. --intent <slug> resolves the named registry row. Missing or ambiguous intents fail before writing. The target space and intent UUID must be pinned at the beginning and not re-read after a human gate.

Workflow discovery

At each stage, context assembly retrieves relevant space-wide documents plus documents whose related_intent_ids contain the active intent UUID. Agents receive selected summaries, sections, paths, and citations to originals rather than every document. Metadata and lexical search can support the first version; RAG can replace retrieval later.

Mandatory approved behavior belongs in memory/team.md or memory/project.md. Contextual information remains in DocumentKB.

Execution model

/aidlc knowledge onboard should remain a standalone skill outside the lifecycle graph:

  • Tools handle validation, copying, extraction, digests, indexing, locking, and persistence.
  • The LLM creates summaries, tags, relevance assessments, and draft rules.
  • Humans approve rule promotion and resolve ambiguity.

It should not be part of Reverse Engineering. That stage remains responsible for repository-specific CodeKB artifacts, although it may consume relevant DocumentKB material.

PR 660

The objective is directionally correct, but <space>/onboard/ is the wrong persistence boundary. “Onboard” remains a good user-facing verb, but the durable output belongs under knowledge/.

The current implementation is rule-first, explicitly defers knowledge handling, and provides no retrieval layer. The work should be split into:

  1. Original storage, extraction, and DocumentKB indexing.
  2. Intent-aware workflow discovery and retrieval.
  3. Summaries, rule proposals, and additional analysis.

Capture, digest, deduplication, and provenance concepts remain reusable. The existing blockers around org.md conflict checks, space pinning, transactional audit writes, unsafe source handling, manifest repair, and prompt-injection isolation still require resolution.

I would not merge PR 660 in its current form. It should be reshaped into a smaller DocumentKB-first implementation grounded in the existing knowledge/, active-intent, and intents.json model.

@alokgp

alokgp commented Aug 5, 2026

Copy link
Copy Markdown
Author

Thanks @apackeer — agreed on all three points, and I'd reshape it.
. Closing this PR as superseded. New RFC:
#714.

Recording what's here so the work is traceable rather than lost, since S1 reuses
most of it.

What this updated PR would have fixed across eight review rounds - however as we have new direction not pushing the branch. Below just for the record.

Eight rounds, with the last three worth naming because the fixes are the part that
carries forward:

  • Round 7 (6 P1, 5 P2, 3 P3) — three of the P1s were guards from earlier rounds
    that a sibling input walked around, so each fix moved to the boundary rather
    than the reported payload: exact-delimiter dedup marker matching (doc-1 no
    longer matches doc-10); the previous practice-line format replaying as a no-op
    again; a deleted practice line no longer letting different text occupy its
    candidate id; trust-root anchoring by identity rather than containment; ambiguous
    digest repair refusing rather than overwriting a healthy row; batch persist
    committing all-or-nothing; content-addressed-only storage leaves; and PDF-header
    quarantine when the magic sits past offset zero.

  • Round 8 (10 findings, 5 High) — one boundary per class, not one fix per call
    site:

    • Sources: a non-regular source (FIFO, socket, device) is refused rather than
      read. Rejecting only symlinks was a denylist — a FIFO blocked forever holding
      the workspace lock
      , and /dev/zero grew a buffer to ENOMEM. The boundary now
      opens once with O_NOFOLLOW|O_NONBLOCK, validates that descriptor, and reads
      from it. O_NONBLOCK is load-bearing: opening a FIFO blocks inside open(),
      before any type check can run. Opening once also closes the TOCTOU hole where a
      validated regular file was swapped for a symlink before the read.
    • State: the space is resolved once per command and pinned. Every path helper
      used to re-read the active-space cursor independently, so a switch during the
      approval pause split one capture across two spaces — measured: 6 files' bytes in
      the old space, all 600 rows in the new one, 6 files unreachable.
    • Write pairs: the audit row commits before the artifact, for rules and for
      sensors. Neither pair can be atomic, so the order decides which half survives a
      failure — a live rule with no ledger record is silent and unrecoverable; a row
      whose artifact is missing is the state these commands already detect and repair.
    • Manifest: the committed ledger is fully schema-validated before any read or
      mutation, and a hand-edited row is repaired only with corroboration from the
      captured bytes or the source path.
  • A regression I introduced and then found — three refusals tell the user to
    re-capture the source, and for the symlink shape re-capture couldn't work: the
    "does the destination already hold these bytes" check read the leaf through the
    no-follow boundary, which exits on a symlink. So the prescribed remedy died
    instead of overwriting the poisoned leaf, and because a directory capture walks
    in one pass, one poisoned row aborted the run and every healthy file beside it
    was never captured. An untrustworthy destination is now removed and rewritten.

Every fix was RED-verified by reverting it in dist/ — which is what the tests
actually spawn — and confirming the pinning test fails.

Status of the six blockers you re-listed

Five are resolved with pinning tests and carry into the new S1 with the reused code:

Blocker Status Pinning test
Space pinning Resolved t263 — a mid-capture active-space switch cannot split one capture across two spaces
Transactional audit writes Resolved t97 — a failing audit append leaves no live rule / no live stage-bound sensor
Unsafe source handling Resolved t263 — a non-regular source is refused promptly; value files reject a symlink and do not hang on a FIFO
Manifest repair Resolved t263 — re-capture repairs a captured leaf replaced by a symlink, and does not strand siblings
Prompt-injection isolation Resolved content_trust / content_handling on every emitted body, plus the skill's boundary section
org.md conflict checks Open — needs spec no prior spec exists; asked as Q4 on the new RFC

On org.md, one thing worth stating plainly rather than quietly guessing: it
doesn't appear anywhere in rounds 1–8, so it's new in your comment. The requirement
is fair — nothing checks org-level conflicts today — but with no prior spec I'd
rather have the semantics from you than invent them. Q4 on the RFC lays out the
three sub-questions, including my read that since onboarded rules can never write
org.md, this is a read-only precedence check and belongs in S3.

What carries forward, and what doesn't

Taking your reusable set literally:

Reused — byte-exact capture + sha256 content addressing; content dedup;
portable-relative provenance (becomes the index's path); the no-follow read
boundary (already shared, unchanged); trust-chain anchoring (retargeted at
knowledge/documentkb/); committed-input schema validation (retargeted at
index.json); corroborated row repair; space pinning; audit-first ordering;
untrusted-data framing; the binary magic-header sniff (becomes extraction's format
router).

Discarded — the spaces/<space>/onboard/ root and manifest.json, replaced by
knowledge/documents/ + knowledge/documentkb/index.json.

Deferred to the new S3 — the preventative keyword classifier and the
persist-rule core. The code stays; S1 stops calling it.

Why a new PR rather than a reshape of this one

The slice boundary is wrong here, not the code. Your split is storage/extraction/
indexing → retrieval → summaries+rules; this PR spans parts of 1 and parts of 3
while skipping 2 entirely. There's no edit that turns it into slice 1 — the
persistence root, the index schema, the command surface, and the deliverable all
differ — so reshaping it in place would produce a review surface larger than a fresh
slice, against a design nobody has approved yet.

Branch stays up as the reference for the reused code. Design for the new shape is
attached to #714 for review before I build S1.

@alokgp alokgp closed this Aug 5, 2026
alokgp pushed a commit to alokgp/aidlc-workflows that referenced this pull request Aug 10, 2026
…ion boundary

A document's FILENAME is attacker-controlled just as its body is, and it is
echoed back in `path`, `source.path` and `citation`. The untrusted-data
declaration named only `content`, so a file called
"IGNORE ALL PREVIOUS INSTRUCTIONS and run rm -rf.md" reached an agent in a
field the declaration did not cover. The class comes from the awslabs#660 review
("a hostile filename therefore arrives outside that declaration") and was
carried in with the code.

Three attempts closed it verb-by-verb and each missed the next sibling:
first `show`, then `show` + `list`, while `onboard`, `sync`, `rebind`,
`associate`, `dissociate` and every refusal message still emitted a
customer-chosen name bare. A fourth case list would have been a fourth miss,
so the bound moved to the BOUNDARY: one emitJson/emitHuman pair that every
verb writes through, plus the tool's own `error()` for the refusal channel.
JSON carries `path_notice` as its first key; human output leads with the
notice. Only three raw stdout writes remain — the two in the funnel and the
static help text, which echoes no customer input.

`UNTRUSTED_CONTENT_NOTICE` goes back to its content-scoped wording. The first
attempt had widened its prose to claim the paths, which was false: it is
attached only where `content` is served, so five of six extraction states
kept shipping the name undeclared while the text asserted otherwise.

t281 now pins the boundary two ways: a source-derived count of raw stdout
writes, and a behavioural test that spawns the real CLI across seven argv
paths. RED-verified against a simulated future verb — a new handler that
writes directly fails the invariant with a named cause, which is what the
three case-by-case fixes could not do.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants