Security model

What Keep Shipping defends, who it defends it from, which control covers which threat, and what is still open.

This document describes the design as it is today. Where a control is designed but not yet wired, it says so and links the issue; a control that does not exist yet is never written as though it does. It feeds #117.

What is worth defending

AssetWhy it is worth something
Cloud and cluster credentialsA run that can assume a role can write to an account; a step handed the apply role can apply
Registry push rightsA push under a trusted image name is a supply-chain event
SecretsThe values themselves, and the fact that a step could hand one back
The integrity of what shipsThe digest that reaches production came from this pipeline
The approval record"A person looked at this" is a claim the engine makes in its own name

The last two have no clean owner outside the engine. A credential belongs to a cloud provider and a secret to whoever set it; what ships and who said yes to it are this tool's own claims.

Who we defend against

Trust boundaries

BoundaryWhat crosses itThe rule
Laptop → CIA run context: actor, workspace, OIDCA local run can never be CI; a CI identity comes only from a minted OIDC token, and the binary builds no run context at all (run_context.rs, see residual risks)
Runner → hosted serviceAn approval verdict, a baselineThe service may say who decided, never who may (hosted-approvals)
Engine → step adapterPortsA step holds only the ports it declared (step.rs)
Engine → TypeScript scriptA subprocessA Deno process with an explicit argv and no other permission flags (deno-script-host)
Repo file → org baselinepolicy:, auto:, block publishersThe file may narrow, never widen (policy.rs)

A malicious or confused coding agent

ThreatControlWhere
The agent writes itself an auto: that waves everything throughauto_approves conjoins the destroys guard whatever the file wrote, guards_destroys accepts only a top-level and, and an unreadable destroy count is an ask, not a zero. The checker reaches the rule as a KS0701 warning on an approval step; no run path evaluates it yetauto_rule.rs, check.rs, #106
The agent hides a decision inside a comparison and approves on itresolve checks provenance for every step the rule actually read, not the steps it mentions — as written, and unwired like the rule aboveauto_rule.rs
A decide step had no model, or the model call failedks-jev returns Err on every failure rather than an escalate, and resolve maps a missing or errored model to an ask. But no run populates DecisionSource today, and the engine's decide step turns model: none into an escalate at confidence 1.0 as ordinary outputs, and a model failure into a failed stepjev, decide.rs, #104
The agent reads secrets it was not issuedActorGatedSecrets refuses a non-allowlisted reference to an agent run with ForbiddenForActor, but it is constructed only by its own tests. The gate a run does get is PolicySecrets, which refuses every reference when the baseline says never: read secrets — on resume (see residual risks)secrets.rs, policy_gate.rs, #103
A step returns a secret in its outputsreject_secret_outputs runs before the schema check, over strings, numbers in Display form, list items, map keys and values, and the output names. A secret in a value is reported against its field name; an output named after a secret is reported as <output name>step.rs, #91
A secret reaches the logStepLog redacts every message and every field name and value, and RunEvent::redacted scrubs every variant before Journal::emit appends; a failed append fails the step. Two gaps: keepshipping approve appends its approval events straight to the JSONL log with no redactor, and the CLI's script-step logger holds an empty onejournal.rs, step.rs, approve.rs, #46
A script step in production without an action:An agent's run refuses to start over such a step in prod or production, unless an approval step that will actually run reaches it through the graph. keepshipping run enforces this; keepshipping resume does not check itmain.rs, script_step.rs, #89
The agent claims to be a human--as agent names an agent and ActorBaseline carries the org's agent list and its require_human rule, but every call site passes ActorBaseline::default(), so the list is empty in the binary today. Establishment::CiOidc is the only strong identity, and no binary path builds the run context that would mint one; detect_local_actor never answers Ci, though --as ci sets the actor directly (see residual risks)actor.rs, run_context.rs, #119

A compromised dependency

ThreatControlWhere
A block from an unknown publisherOnly identities in the org baseline's publisher list are trusted, compared exactly; unverifiable is refused, with no third "probably fine" case. verify is written and has no call site yet (see residual risks)block_trust.rs, #84
A block that declares its own policyRefused by the checker (KS0405)check.rs
A TypeScript step reaching past the workspacePinned Deno by version and archive sha256; --allow-read limited to the workspace, the embedded bootstrap and the secrets FIFO; --allow-env, --allow-write, --allow-run, --allow-ffi, --allow-sys are all absent; network only to the hosts the step's own source declaresdeno-script-host, ADR 0009
A symlink inside the workspace pointing out of itThe walk hands every escaping link to --deny-read; unresolvable links are denied, not trusted; a link whose path contains a comma cannot be spelled as a deny grant, and refuses the run insteaddeno-script-host
A step with a manifest but no lockfileRefused; --frozen-lockfile otherwisedeno-script-host
A Rust dependency with a known advisorycargo deny in CI; unknown registries and unknown git sources denieddeny.toml
The adapter crate itselfNothing — adapters are statically linked and run in-processsee residual risks

ks-jev is the shape a decision adapter is expected to take: a pinned model, three attempts by default (with_retry can raise the count and the wait cap), a bounded wait that a hostile Retry-After cannot extend, an answer checked against the question that asked it, and an API key read inside with_bytes and named in no message (jev, #105).

A malicious pull request from a fork

The answer the classifier gives is short: a fork run reads no secret, applies nothing, and checks only. Nothing in the binary enforces it yet — see residual risks. GitHub already withholds secrets from pull_request runs of forks, hands them a read-only GITHUB_TOKEN, and mints no OIDC token; the harness answers the same question itself, so that what a run may do does not rest on which trigger a workflow file happened to pick:

let origin = ks_github::Origin::of(event_name, &payload);
origin.may_read_secrets();  // false for a Fork
origin.may_apply();         // false for a Fork
origin.check_only();        // true  for a Fork

The GitLab adapter and generic --ci mode answer the same question from their own variables, and both fail closed — see GITLAB_CI.md and CI.md. The rules below are GitHub's, read from the event payload:

fork.rs classifies:

Rules and examples are in tests/fork.rs. Two things follow, and neither is the classification's job:

An insider approving their own change

ThreatControlWhere
Approving your own runApprovalRequest::accepts refuses approver == proposed_by, refuses a non-human approver, and refuses a verdict bound to different artifacts — in the core port, and no adapter implements around it. The engine calls it from denial::decide, which has no call site of its own yet (see residual risks)approval.rs
Approving a plan that changed under youThe approval binds the exact digests; a resume re-hashes ship.ks, re-checks every approval's expiry, and re-checks that each still covers the digests the run holdsstate.rs, #42, #121
An approval from a comment the attacker wroteThe adapter ignores a proposer, a bot, or a non-member; a failed membership lookup counts as not a member and the run stays parkedgithub, #96
An agent approving with --as adakeepshipping approve has no --as; the name is a log label, not a credentialapprove.rs, #95
A member of nobody on the teamA separate Directory::is_member lookup in the same decide; a roster failure is an error, never a refusaldenial.rs
Holding a runner open for a nightA run parks at the approval and a later job resumes it from the ticket#97

A compromised hosted service

The hosted approval adapter holds tickets in memory and carries two transports (a web review form over a caller-minted session, and a Slack interaction). It decides who decided, never who may: the engine's denial::decide runs accepts and the Directory lookup itself, on every verdict including refusals, so a service that answers with a confident approved releases nothing. The first decision wins; nobody deciding is Ok(None), never an approval. decide has no call site yet, so today that is a property of the library rather than of a shipped path.

A Slack user's identity is their immutable Slack id, and the request signature is verified by the adapter itself, with a five-minute replay window. A web approver is a session, never a form field — though that invariant is caller discipline today, not a type-level guarantee, and there is no GitHub sign-in in this tree to mint such a session yet.

Nothing in this repository stores a hosted credential. The waitlist venture holds its own (TURNSTILE_SECRET, OWLPOST_API_KEY, RESEND_API_KEY). Only the captcha closes: with no TURNSTILE_SECRET no captcha port is bound, and the waitlist module refuses joins in ENV=production. The mail secrets are open by design — with no mail key the join is still captured as pending and no mail is sent. Its origin guard is a browser-CORS convenience restated as a refusal: a CORS layer only adds headers and never rejects a request, so the writes are refused before the handler runs on their own. It reads the Origin header only, so it stops cross-origin browser writes, not a scripted client.

Policy, credentials and the shipped artifact

Residual risks

Stated plainly. Each is a real limit, not a caveat with a plan attached.

Reporting a vulnerability

Report privately, not in an issue. Email contact@keepshipping.run.

That address is the published channel. It is what https://keepshipping.run/.well-known/security.txt carries (Contact: mailto:contact@keepshipping.run, Expires: 2027-09-23T00:00:00.000Z, Preferred-Languages: en), and the file names no other one. If the repository has private vulnerability reporting switched on, its advisory form reaches the same mailbox; email works either way.

What helps: the version or commit, the ship.ks and the step, what the attacker controls, and what they got. What to expect: an acknowledgement, then a fix or a plain statement of why the report is not one.

See also