guild-cli is a local file-based CLI for managing small-team artifacts
(members, requests, reviews, issues, agora plays, devil-review reviews).
It is not designed for multi-tenant or network exposure. The trust
boundary is "anyone with write access to content_root".
Since v0.4.0, guild-cli ships devil-review — a third passage
explicitly designed as a security-knowledge-floor substrate for
code reviewed by authors who haven't met OWASP top 10. It is not a
replacement for this Security Model document or for upstream tools
like Anthropic /ultrareview, Claude Security, or
supply-chain-guard.
It composes with them: the upstream tools' findings flow in via
devil ingest --from <source>; multi-persona deliberation happens
on top of the substrate.
What devil-review enforces (relevant to this threat model):
- Catalog-enforced lense coverage at conclude. A reviewer cannot
conclude a
devil-reviewsession without leaving at least one entry per lense in the v1 catalog (12 lenses, including the Claude-Security-aligned 8:injection / injection-parser / path-network / auth-access / memory-safety / crypto / deserialization / protocol-encoding, pluscomposition / temporal / supply-chain / coherence). Akind: skipentry with declared reason satisfies coverage; silent skipping is refused. The friction is the floor. supply-chainlense mandatory delegate to SCG. Whendevil ingest --from scgis invoked, the verb runtime-checks forscgonPATH(POSIXwhich scg/ Windowswhere scg) and refuses if absent. Documented intent is now runtime-enforced (PR #129 e-001 fix).- Severity rationale required on findings.
kind: findingentries require both--severityAND--severity-rationale. The rationale forces exploitability-context reasoning: same category may carry different severity in different repos (Claude Security influence; the rationale is what makes that decision auditable). - Append-only audit trail for dismissals.
devil dismissrequires a structured reason from a fixed enum (not-applicable | accepted-risk | false-positive | out-of-scope | mitigated-elsewhere). The substrate keeps the dismissal trail; re-dismissing a dismissed entry is refused. Future audit can grepdevil/reviews/*.yamlfor "what did we say about this category", not just "what passed".
What devil-review does NOT enforce (read this carefully):
- It does NOT prevent insecure code from being merged. A reviewer
who skips every lense with
irrelevant because n/aand concludes with empty synthesis can pass the gate. The substrate captures the dismissal so a future audit can see the decision was made; it doesn't prevent the decision. - It is NOT a code scanner. Its
ingestverbs depend on upstream tools producing the strict v0 input JSON shape. Real-world adapter shims that translate/ultrareviewbugs.json/ Claude Security findings export / SCG verdict output into devil's shape are out of scope for the in-tree passage and would land as separate utilities (or in the source tools themselves). - It is shape-mismatched for general bug-fix review. See
docs/playbook.md§ "When NOT to use devil" — routine bugs (off-by-one, null checks, UI fixes) don't fit the security-shaped lense catalog and would degrade the substrate with cargo-cult skip-with-reason entries. Usegate reviewwith the configurable lense list (defaultdevil / layer / cognitive / user) for general code review.
Trust assumption (named explicitly per PR #129 e-001 / e-002 fix
and propagated to agora in PR #132): devil-review's optimistic
CAS is sequential, not atomic. The same trust assumption applies
across all passages — one CLI process at a time per content_root.
Under that assumption, CAS catches the load-then-act-then-write race
that AI agents naturally produce when re-entering between sessions.
Under true OS-level concurrent writers (two processes hitting the
same record in the same scheduler quantum), last-write-wins
semantics apply. File locking is out of v0 scope.
For full details: src/passages/devil/README.md,
issue #126
(design rationale), and docs/playbook.md (combos with gate /
agora).
Day-to-day repo-specific rules for contributors and review tools live in
docs/security-checklist.md. Findings from
upstream tools flow into the substrate via devil ingest --from <source>.
- Path safety — every filesystem op goes through
infrastructure/persistence/safeFs.ts, which rejects any target that does not resolve under the configured base directory, and refuses to follow symbolic links anywhere on the path. - No shell execution for data processing. The only
child_processusage isspawnSyncin array form (no shell expansion) for the interactive editor ingate review— see Trust Assumptions below. All persistence, parsing, and state mutation paths are in-process with no subprocess calls. - Input validation at the boundary:
MemberName—^[a-z][a-z0-9_-]{0,31}$+ reserved-name blacklistRequestId/IssueId— strict date-sequence regexVerdict,Lense,RequestState,IssueSeverity,IssueState— enum parsing rejects unknowns
- Text sanitization — free-text fields (
action,reason,note,comment) are stripped of ASCII control characters (except\n\t) and capped (4 KB for request fields, 2 KB for issue text). The sanitization policy has a single source of truth atsrc/domain/shared/sanitizeText.ts; every caller (Request, Issue, Review, MessageUseCases) re-exports the same strip-and-cap invariant rather than maintaining its own copy. - State transitions —
assertTransitionrejects illegal moves (e.g.completed → approved). - Issue audit trail (state_log). Every
Issue.setStatecall appends tostate_log: [{state, by, at, invoked_by?}](append-only, max 100 per issue), parallel to Request'sstatus_log. Anopen → resolved → open → resolvedflap stays distinguishable from a single resolve.gate issues resolve / defer / start / reopenrequire--by <m>(orGUILD_ACTOR) — the transition cannot be recorded without an actor. - Strict CLI flag validation. Every write verb declares its known
flag set and rejects unknown flags via
src/interface/shared/parseArgs.ts#rejectUnknownFlags. Typos like--executr noiror--catgeory proerror with a listing of valid flags instead of silently falling through to defaults. Applies to:register,request,approve,deny,execute,complete,fail,fast-track,review,thank,message,broadcast,inbox,inbox mark-read,issues add|list|note| promote|resolve|defer|start|reopen,repair, and — among read-only verbs —tailanddoctor. - Denial-of-service caps — directory listings (1000), reviews (50 per request), status log (100 per request), issue state log (100 per issue), inbox messages (500 per member).
- YAML safety — parsing goes through
yamllib's default schema which refuses custom tags. - Identity resolution chain (#407) — actor identity goes through
three distinct steps that must all agree:
GUILD_ACTORenv var is the actor claim (also the trail-author value recorded into requestfrom/by/executorsfields).members/<name>.yamlmust exist, parse as a YAML mapping, and successfullyhydrateto count as a registered member. An empty or malformed file does NOT promote<name>to actor status —assertActorconsultsfindByName(parse + hydrate), not bare file existence. (Pre-#407 this was filename-only, sotouch members/ghost.yamlwas enough to slip past--by/--from/--executorsvalidation whilewhoamialready classified the same actor asunknown.)- The
name:field insidemembers/<name>.yaml, if present, must equal the filename stem. A divergence (e.g.members/alice.yamlcontainingname: leysia) is treated as malformed byhydrateand the record is rejected — neitheralicenorleysiais promoted to member status from such a file. The operator must either rename the file or fix the field.
- Editor invocation.
gate reviewspawns the user's editor via$GIT_EDITOR/$VISUAL/$EDITORenvironment variables. The editor command is not validated — the tool trusts the local environment. In multi-user or container environments, restrict environment variable mutation or avoid interactive review. - Plugin trust model. See "Plugin trust model" below for the full surface (currently doctor plugins; verb / hook / transform plugins planned under #36).
- MCP server (gate_mcp.py). Spawns
gateas a subprocess viaasyncio.create_subprocess_exec(array form, no shell expansion). Project name validation blocks path traversal (/,\,.,..).
guild-cli allows operators to extend the CLI through plugins listed
in guild.config.yaml. Today only doctor plugins are loaded; the
verb / lifecycle-hook / content-transform plugins specified in
#36 Phase 1 will
share the same trust model when they ship.
Execution context. Every plugin is loaded as an ES module via
Node's import() and runs in the main process with full
Node.js capabilities — file system, network, child processes,
environment access. There is no sandboxing. A malicious plugin
has the same authority as the user running gate / guild /
agora / devil / ctx.
Consent surface — trusted: true guard. guild.config.yaml
must declare doctor.trusted: true (and, when the broader
plugin-loader ships, the equivalent plugins.trusted: true) before
plugin paths under that section are loaded. Without the trust
declaration, the loader warns and skips every plugin path
listed under it — the YAML alone is not consent. This is the same
guard added in #90
for doctor plugins (src/infrastructure/config/GuildConfig.ts).
Rationale: a teammate's git pull should not silently start
running new code on your machine just because a plugins: entry
landed in the config.
Origin discipline. Load plugins only from sources you would type credentials for. The model is "whitelist by author", not "vet by review" — code review of plugin diffs at PR time is a useful backstop, not a substitute, for knowing the author.
Plugin loader failure is non-fatal. A plugin path that fails to
load (missing file, syntax error, throw on import) surfaces as a
gate doctor finding rather than crashing the CLI. The same
behaviour will apply to verb / hook / transform plugins when their
loader ships — read verbs (gate boot, gate show, etc.) must
remain available even when an extension is broken.
Source discrimination via gate schema. Every verb in the
gate schema output carries source: 'core' | 'plugin' so an MCP
wiring or LLM tool layer can filter built-in verbs from extensions.
The discriminator is part of the schema contract (see
docs/POLICY.md § "Plugin stability") — a
consumer that whitelists source: 'core' is guaranteed to get the
built-in surface only.
What this is NOT. Trusted-plugin loading is not a security boundary against the plugin author; it is a deliberate handoff of authority from the operator to the plugin code. Untrusted code should not be run as a plugin under any configuration.
Each item below is tracked as a GitHub issue for visibility in the backlog; status here is the current load-bearing summary, status on the issue is the active discussion.
-
Error messages may leak absolute paths (#153). Mitigated as of v1-prep #153 (boundary sanitize): each CLI's top-level
catchrewrites occurrences of the configuredcontentRootprefix to the literal token<content_root>before writing to stderr (src/interface/shared/sanitizeError.ts). The structural tail (<content_root>/requests/pending/foo.yaml) is preserved so debugging is not impaired. Paths outsidecontentRoot(e.g./etc,/tmp, accidental absolute paths in user input) are not sanitized — boundary sanitize only collapses the host-specific prefix thatsafeFsresolves into. -
Prototype pollution from hostile YAML (#154). Mitigated as of v1-prep #154 (defense-in-depth):
parseYamlSafepasses the parsed tree throughstripPrototypeKeysbefore returning. The walker rebuilds plain objects viaObject.create(null), dropping__proto__/constructor/prototypeliteral keys at every nesting level. Defence is now layered: (1)MemberNamerejects the three names at the domain boundary, (2) theyamllibrary returns plain objects (upstream guarantee), and (3) parseYamlSafe strips them independently regardless of what the lib does. Class instances pass through unchanged so YAML custom tags that produce e.g.Datecarriers are unaffected. Tests:tests/infrastructure/parseYamlSafe.test.ts. -
Concurrent writes (#155). Mitigated as of v1-prep #155:
withGuildLockis the serialization boundary. A content-root-wide single-writer lock at${contentRoot}/.guild-lockis acquired by per-entry middleware (withEntryLock) on every write-classified verb across all five entries (gate,guild,agora,devil,ctx). Mechanism:O_CREAT | O_EXCLon the lock path, JSON metadata payload (pid / ppid / started_at / verb / actor / host / cwd / passage / guild_cli_version),unlinkinfinally. Competing acquire surfaces asLockBusyError(DomainError subclass; JSON envelopecode: "lock_busy").Stale reclaim covers three cases on
EEXIST: (a) the recorded pid is dead (kill 0→ESRCH) and is neither the current process nor its parent; (b)lock.started_atpredates the current OS boot (os.uptime()-derived); (c)GUILD_LOCK_MAX_AGE_MSenv is set and the lock is older than that bound. Cross-host auto-reclaim is forbidden — different hosts sharing one content_root are off the supported substrate (see iCloud / NFS note below).The pre-existing per-record CAS (
RequestVersionConflict,InboxVersionConflict,IssueVersionConflict) is retained as an in-process safety net against bugs that reorder writes within a single process; it is no longer the cross-process barrier.Out of scope (tracked separately): #194 (
--format jsonenvelope parity foragora/devil/ctx), #195 (malformed.guild-lockrecovery), #196 (lock metadataactorempty whenGUILD_ACTORunset), #197 (TOCTOU betweenEEXISTandreadHolder), #200 (<write-verb> --helpacquires the lock), and remote-FS support (NFS / SMB / iCloud Drive —O_CREAT | O_EXCLatomicity is not guaranteed there; running guild-cli against a content_root on a remote FS is unsupported).Threat-model note: an attacker who already has write access to the content_root can write any YAML directly and is therefore out of scope for the lock mechanism — the lock is for honest concurrent writers, not for adversarial ones.
Security issues: open a private GitHub Security Advisory on the repo.