Cortex / core
AGENTS.md1 starsChanged 18 days agoMITView on GitHub
The modules both index/ and mcp/ depend on. Everything security-critical in Cortex lives here, which is why the directory is small and stays small. - core/ imports nothing else in this repo. Not index/, not mcp/. Enforced by test/architecture.test.js; if you need something from a leaf, the dependency is backwards. - Every caller-supplied path goes through resolveInRoot. It realpaths the nearest existing ancestor, so a symlink escape is caught even for a file that does not exist yet. projects.js once…
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,843 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository43 other files it gives its agents.
- AGENTS.mdAGENTS.md
- evals/AGENTS.mdAGENTS.md
- index/AGENTS.mdAGENTS.md
- mcp/AGENTS.mdAGENTS.md
- tools/AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- architecture-reviewSkill
- audit-notesSkill
- challenge-planSkill
- change-impactSkill
- clarify-requestSkill
- connect-mcpSkill
- create-teamSkill
- daily-planSkill
- debug-issueSkill
- define-termsSkill
- design-specSkill
- end-of-day-summarySkill
- handover-notesSkill
- join-teamSkill
- merge-workSkill
- migrate-legacy-engineSkill
- personal-setupSkill
- quick-noteSkill
- register-projectsSkill
- repo-mapSkill
- resolve-conflictsSkill
- resume-workSkill
- retrospectiveSkill
- review-changeSkill
- set-profileSkill
- setup-repoSkill
- story-mapSkill
- suggest-skillsSkill
- summarize-filesSkill
- sync-websiteSkill
- trim-agent-docsSkill
- update-pluginSkill
- weekly-reviewSkill
- whats-changedSkill
- write-agent-docsSkill
- write-area-briefSkill
- write-context-docsSkill
The file
We split this AGENTS.md into the rules it gives an agent
1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 211
WORK_TOOLSis data, and short on purpose: Every row refuses a person who uses that tool for a project of their own.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
core/ — the shared kernel
The modules both index/ and mcp/ depend on. Everything security-critical in Cortex lives
here, which is why the directory is small and stays small.
Invariants
core/imports nothing else in this repo. Notindex/, notmcp/. Enforced bytest/architecture.test.js; if you need something from a leaf, the dependency is backwards.- Every caller-supplied path goes through
resolveInRoot. It realpaths the nearest existing ancestor, so a symlink escape is caught even for a file that does not exist yet.projects.jsonce skipped this andgetProjectContext(root, "../../secret")read any file on disk. rootFromEnvinpaths.jsis the one place the root's two names are put in order.CORTEX_ROOT, thenAI_OS_ROOT(the name before #552, still read, never warned about); an empty value is unset; neither set isroot: nulland nothing is guessed. It reads the strings it is handed and never the disk. No other file incore/names either variable, andprofile.jsleast of all: it readsCORTEX_PROFILEonly.test/root-from-env.test.jsholds both. The shell counterpart iscortex_rootintools/_cortex-lib.sh, andtools/test/cortex-root.test.shruns the two over every pairing.scrub.jsrefuses; it never sanitises.assertWritablethrowsRefusedWriteErrorand the write does not happen. Silently rewriting a developer's note to remove a secret is a worse failure than declining it with a reason.- No error message may echo a secret.
redact()exists for this;RefusedWriteErrornames the kind of secret and its line, never the value. gate.jsis the publish gate, and both callers go through it —append()here andassertPublishableinmcp/lib/tools.js. Three checks, in order: a credential (scrub.js), the employer denylist (denylist.js, only wherepolicy.refuses === "employer"), personal data (scanPersonal, byCORTEX_PII, elsepolicy.pii). Add a fourth check here, never at one caller. Every refusal is aRefusedWriteErrorsubclass with its owncode(employer_denylist,personal_data), so a handler written for credentials still stops the write. ADR 0026, ADR 0027.denylist.jsmatches names the person gave it; it detects nothing. The file lives outside any repo (CORTEX_DENYLIST, else~/.cortex/employer-denylist) and never ships in a fixture with a real name. No file at the default path is no check, and preflight says so; aCORTEX_DENYLISTset to no readable file refuses every published write onhome, because a typo must not switch the check off. A bad line is aproblemwith its line number and the rest still apply. A refusal names the entry's kind and line in the denylist — never the matched text, never the entry. It readspolicy.refusesand no profile name; tests hand it a HOME of their own.destinations.jsdecides where an outward call may go, frompolicy.destinations—any(home),approved(work),none(lab, which still allows a pull or a clone). The list isCORTEX_DESTINATIONS, else~/.cortex/allowed-destinations, outside any repo. Onapprovedno list refuses everything (absentandmissingboth): a list of what to allow fails closed, the opposite of the denylist. The host matches exactly and case-insensitively, never by suffix; a path prefix matches whole segments; a.or..segment and an address that cannot be parsed never match. Local paths,file://and loopback http(s) are always allowed; a UNC path is a remote host. A refusal isDestinationRefusedError(unapproved_destination) built from the normalised host and path, so no userinfo can reach the message. A missing policy, or one withoutdestinations, throws a TypeError. ADR 0030.policy.piiis the personal-data mode whenCORTEX_PIIis unset (refuseon work,warnelsewhere), andgate.jspasses the policy togatePersonalfor it.CORTEX_PIIstill wins.- The personal-data scan reports kind and line only — not even a redacted value, since a
half-starred email address is most of one. It is conservative on purpose: a phone detector that
fires on dates and versions is a gate people learn to skip, so add a false-positive case to
test/personal-data.test.jswith every new shape. An unknownCORTEX_PIIthrows, like an unknown profile. - Memory is append-only.
append()writes withappendFileSyncand nothing else: no entry is rewritten and no file is moved, renamed or split, a day file included. A feature that mutates an existing entry brings back the lost update (ADR 0002). Append-only does not make two writers merge: two branches that append to one file on one day conflict. The layout in the writer bullet below is what prevents that, by giving each author a path of their own. list()andrecent()read two memory layouts, and are the only code that knows either. A day ismemory/<date>.md(a day file, no author) or a directorymemory/<date>/holding one<author>.mdper author, where the author is a slug matching[a-z0-9][a-z0-9-]{0,39}. One date can hold both, and then every file is its own row: nothing merges, replaces or deduplicates rows, so one layout cannot hide the other. Rows are{ day, author, path }, newest day first; within a day the day file, then authors by slug in code-unit order, so the order depends on the names alone.authorcomes from the path and isnullfor a day file. Never read it from the heading, which is text anyone can edit.recent({ days })counts days, not files. Anything else in the directory is skipped. Every reader inindex/andmcp/calls these two; a second listing of the directory is how a repo that only has day directories gets told it has no memory. The design, "Layout" and "Readers".append()writes one file per author per day, and finds the author itself. The path ismemory/<day>/<author>.md, headed# <day> · <author>. The author is theauthoroption, elseCORTEX_AUTHOR, else gituser.nameread in the repo that holds.cortex(resolveAuthor,author.js). Only the slug fromauthorSlugis written: never the raw name, and no email is ever read. The rules a change must keep:- No caller passes the author in production. The writer resolves it, so neither the CLI nor
remembercan forget to (ADR 0016). The option,envandgitexist so a test names its author and reads no machine identity. - No usable name is not an error. The entry goes to the day file
memory/<day>.md, and the result carrieslayout: "day"and anoticeon every such write. Each caller passes the notice on. A day file is the layout that conflicts between branches, so the fallback must never be silent. - A name someone set and got wrong is refused:
invalid_author, forCORTEX_AUTHORand for the option. It is never swapped for git. - The order is: empty, the publish gate (
gate.js), the root check, the author, then the disk. A refused write leaves no file and no day directory, andappend()never creates.cortex/.test/memory.test.jscompares the whole tree after each refusal. author.jsandindex-file.jsare the two modules incore/that start a process, each one git command as an argument array:git config user.name, andgit ls-filesfor freshness.test/author.test.jsinjects git in every case;indexFreshnesstakestracked.test/memory-fixture.jsbuilds every reader's fixture with this writer. The design, "What names an author".
- No caller passes the author in production. The writer resolves it, so neither the CLI nor
rootmeans the.cortexdirectory, andappend()enforces it. The contract used to live in a doc comment, so passing a repo root — the reading the word invites — wrote a dated file to<repo>/memory/, returned the path it had written and exited 0. Nothing reads there, andgenerated.mjsignores only.cortex/index|findings|view, so it was not even gitignored: a confident wrong output rather than a failure.assertCortexRoot()refuses and names what it was given. Keep the check on the write path — it is the reasoncortex-memory.mjscan rejoin therootProblemcheck the other eightindex/CLIs share.source.jsis the one place that says where Cortex comes from (#622). An organisation's fork names itself in the marketplace entryindex/lib/shared-plugin.mjswrites and in the clone URL ofcortex-review.yml, and both ask here. The order is--source,CORTEX_SOURCE, the connector'ssource, thenmarinvch/Cortex, and an empty value is unset. A value that is set and wrong is refused, never skipped for the next one down: a fork that misspelt its source must not quietly get upstream. A URL carrying a credential is refused throughcarriesCredential, and the message never repeats it. The default's entry stays byte-for-byte what Cortex wrote before.job.jsholds the five jobs and what each is offered first (R1 of the role-packs design).mcp/lib/roster.jsvalidatesmembers[].jobwith it, andtools/cortex-pack.mjsbuilds each role pack fromJOB_SKILLS, the design's table of what each job gets, checked againstskills/bytest/job.test.js. So the roster and the packs cannot disagree. A job decides what is offered, never what is allowed: nothing may refuse an action because of it. No job isdeveloper. Pure.story-key.jsis what a ticket key is, for both leaves (R3 of the role-packs design).index/cortex-stories.mjsfinds keys in commit messages and branch names, andmcp/mirrors stories by key and answersgraph story <key>, so the shapes, the stop prefixes and the message-versus-branch split live here once. The split is what confidence is built on: the branch in a merge subject is never read as written in the message, and a PR number is never a key.repoFromRemotekeeps only owner and name, so a token in a remote URL cannot leave. Pure.gitignore.jsis the.gitignorewriter both leaves share (#620). It appends missing entries under a marker line, never rewrites one, and honours a bare.cortex/the user wrote. Which entries is the caller's:index/lib/generated.mjspasses the generated directories, andmcp/lib/team.jspasses.cortex/team/before it clones a team-brain into a product repo.json-text.jsinserts one entry into a JSON file's text (#623), forindex/lib/shared-plugin.mjsandtools/cortex-setup.mjs. It only finds and inserts, in the file's own indent and line endings. Parsing first, refusing, keeping an existing entry and re-parsing to compare are each caller's job, and both do all four. Pure.route-path.jsis the one normal form for an HTTP path (#621):/items/{}, every variable spelling folded to{}.index/lib/routes.mjswrites the route map and the contract in it, andmcp/lib/contract.jsnormalises a "who calls" question with it.callReachesis the route map's rule — a call's{}meets only an endpoint's{}— so both leaves match the same way. Pure; it reads nothing.graph.js,path-kinds.jsandindex-file.jsare the import graph's shared half (G3 of the code-graph design).index/cortex-graph.mjsprints the graph report and thegraphMCP tool returns it, and the two must agree exactly, so the computation lives here rather than in either leaf. The rules a change must keep:graph.jsis pure: an index in, plain data out. Hubs, bridges, cycles, the bridge score,edgeWeightand Tarjan'scomponents. Its header holds the scoring rules and why each was measured.path-kinds.jsis what a path says alone: the area (layerKeyFor), a fixture (isTestData), code that serves tests (inTestSupportDir).index/lib/langs.mjsbuilds its wider test-like rule fromTEST_DATA_DIRShere, so a fixture directory is added once.index-file.jsreadsindex.jsonandenriched.jsonand decides staleness. One definition of stale, one reader of each file;index/lib/open.mjs,format.mjsandenrich.mjsre-export them. A refusal names the kind (absent,unreadable,version) and the command that rebuilds.symbol-calls.jsis what a caller is (G5): the calls inindex.symbolsthat reach a definition, splitextracted/inferred/ambiguous.cortex-impact.mjs --symboland thegraphtool'scallers,calleesanddefinesboth read it. Pure, likegraph.js: only anextractedcall counts toward a floor.- Everything here is re-exported by the
index/module that used to hold it, soindex/still asks its own modules. Do not import these fromindex/callers directly.
parser-lock.jsis what the parser add-on is, for its installer and its loader (G4, ADR 0029).tools/cortex-parsers.mjsinstalls andindex/lib/parsers.mjsloads, and neither may import the other. The rules it keeps:- The digest is of the lock's meaning: sha256 of the parsed lock with sorted keys, so a CRLF
checkout names the same
~/.cortex/parsers/<digest>/. Any edit to the lock moves the directory, so an install from another lock is never loaded. lockProblemsis checked before anything trusts the lock: exact versions, an https registry origin, a tarball URL that names that package and version on it, sha512 integrity, and file paths with no dot segments, each with a sha256.verifyInstallhashes every file the lock names and answersabsent,partial,mismatchorok. Onlyokmay be loaded. It checks against the lock, never against a manifest the installer wrote.- It never reads the lock's path itself: the caller passes it, because the lock lives in
index/andcore/names no leaf.
- The digest is of the lock's meaning: sha256 of the parsed lock with sorted keys, so a CRLF
checkout names the same
claude-code.jsis data, not policy code. Anthropic's Claude Code rules, each with the sentence on its source page that states it. Checkers read values from it and never hard-code a limit. It holds no I/O and must stay that way — the fetching lives intools/cortex-claude-docs.mjs, which a maintainer runs. A rule no official sentence states does not belong here. ADR 0017.plugin-registry.jsis the one reader of Claude Code's plugin registry (<home>/.claude/plugins/installed_plugins.json).tools/cortex-plugin-check.mjsandmcp/lib/setup-plugins.jsboth need it and may not import each other. It only reads.readRegistryreturns astate, and onlyreadsays what is installed:absentandunreadablemean unknown, never "nothing installed". The directory is a parameter, so a test hands in its own and never reads the machine's.installedHerecounts auserormanagedentry everywhere and aprojectorlocalone in itsprojectPathand below.project-file.jsowns the project file's format, for both leaves. A project file isprojects/<slug>.mdin a team-brain: flat frontmatter, then prose (ADR 0024). The rules it keeps:- Text in, a result out. It reads no file and no environment variable and cannot make a
request. A link is the characters of a URL.
test/project-file.test.jsscans the source for an I/O import. - Validation never throws. It returns
{ ok, isProject, data, errors, warnings }. An error names its line and its key and never repeats a value, because the value may be the secret. - An unknown key is an error, and so is a repeated one. Ignoring a misspelt
trakcer:would drop a link without a word. Do not add a key here without the design spec changing first: teams have these files committed. isProject: falseis not an error to a reader. A README or a note inprojects/has notype: projectline; a listing skips and counts it.- The grammar is the flat subset
tools/cortex-frontmatter.mjsholds skills to, restated here becausecore/may not importtools/. No nesting, no list syntax, no block scalar. profileRefusal(data, policy)readspolicy.refusesand nothing else. No profile name, no environment. A missing policy throws; it is never read as "refuses nothing".profile.jsdoes not change for this.- The employer check is a floor, and the message says so. It recognises a private-network
host and a tenant of a hosted work tool. An employer's repo on a public forge passes. Do not
word any message as if a passing file were cleared. The host is read without the trailing dot
of a fully-qualified name, and from a link with no scheme too: that link fails validation, but
a reader on
homemust still withhold the file that holds it. WORK_TOOLSis data, and short on purpose. Every row refuses a person who uses that tool for a project of their own. A row needs a comment saying why the address names an organisation, and an entry inROW_EXAMPLESin the test, which fails without one. A public forge is never a row.- Rendering does not validate or repair.
renderProjectFileandsetFrontmatterwrite what they are given; the caller validates the result.setFrontmatterreturns everything after the frontmatter byte for byte.
- Text in, a result out. It reads no file and no environment variable and cannot make a
request. A link is the characters of a URL.
Gotchas
stamp()is re-exported frommemory.jsfor convenience, butdate.jsowns it. There was briefly a second copy after the kernel extraction; do not add a third.paths.test.jsskips its symlink case on Windows — creating a symlink there needs admin. The skip is expected in local runs and in the Windows CI leg.scrub.test.jscarries acortex:allow-secretsmarker. Without it, Cortex reports its own test corpus as a critical finding on every run. If you add a new detector, add its fixture there, assembled at runtime — a realistic literal trips GitHub push protection and blocks the push.
Tests
node --test core/test/*.test.jsarchitecture.test.js and plugin.test.js are drift guards rather than unit tests: they fail when
the layering or the plugin packaging rots, which no application test would notice.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

