Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
# CORPOS_MODE=local does not skip the bearer gate.
# CORPOS_MODE=shared
# DASHBOARD_API_TOKEN=
# Identity bound to that shared token (not client-supplied on decide/appeal):
# DASHBOARD_OPERATOR_ID=operator@dashboard
# DASHBOARD_TENANT_ID=default

# ── Data ─────────────────────────────────────────────────────────────
# Local libsql / SQLite file used by the firm store (default: data/company.db).
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ jobs:
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
Expand All @@ -37,8 +37,8 @@ jobs:
timeout-minutes: 10
needs: verify
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,17 @@ jobs:
matrix:
language: [javascript-typescript]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@4187e74d05793876e9989daffde9c3e66b4acd07 # v3
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: ${{ matrix.language }}
queries: +security-and-quality

- name: Autobuild
uses: github/codeql-action/autobuild@4187e74d05793876e9989daffde9c3e66b4acd07 # v3
uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4187e74d05793876e9989daffde9c3e66b4acd07 # v3
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:${{ matrix.language }}"
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Check Fly token
id: fly
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@ jobs:
security-events: write

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
- uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.sarif
results_format: sarif
publish_results: true

- if: always()
uses: github/codeql-action/upload-sarif@4187e74d05793876e9989daffde9c3e66b4acd07 # v3
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: results.sarif
category: scorecard
2 changes: 2 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Commands: `npm run dev`, `npm test`, `npm run scenario`, `npm run audit:verify`,
`HttpLLMProvider` when live; `/api/health.mode` reports `live` only then).
- Shared mode (`CORPOS_MODE=shared` + `DASHBOARD_API_TOKEN`) gates approve/kill on
the API via Bearer; console sends `VITE_DASHBOARD_API_TOKEN` when configured.
Decide/appeal bind `decidedBy` and tenant to `DASHBOARD_OPERATOR_ID` /
`DASHBOARD_TENANT_ID` (not the request body).

Site id: `corpos`. Program: `corpos-autonomous-company-r3`.
G1–G6 firm governance coded; TTL scheduler; console Bearer; orchestrator-driven day; live SSE.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Architecture decisions live in [`docs/adr/README.md`](docs/adr/README.md).

## Security

Reference architecture — not production-hardened. `DASHBOARD_API_TOKEN` is required for approve/kill mutations by default; `CORPOS_MODE=local` does not skip the bearer gate. Set `CORPOS_ALLOW_UNAUTHENTICATED=true` only for local simulation. See [SECURITY.md](SECURITY.md).
Reference architecture — not production-hardened. `DASHBOARD_API_TOKEN` is required for approve/kill mutations by default; `CORPOS_MODE=local` does not skip the bearer gate. Decide/appeal bind the decider and tenant to `DASHBOARD_OPERATOR_ID` / `DASHBOARD_TENANT_ID`, not the request body. Set `CORPOS_ALLOW_UNAUTHENTICATED=true` only for local simulation. See [SECURITY.md](SECURITY.md).

## Community

Expand Down
10 changes: 8 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Treat it as a design artifact you can run locally.
- Exception HITL with scheduled TTL fail-closed; kill switch; department capital caps.
- Shared-mode console sends Bearer (`VITE_DASHBOARD_API_TOKEN` / `DASHBOARD_API_TOKEN`).
- Dashboard mutations require `DASHBOARD_API_TOKEN` by default; `CORPOS_MODE !== "shared"` does not ungated them.
- Approve/appeal bind `decidedBy` and tenant matching to `DASHBOARD_OPERATOR_ID` /
`DASHBOARD_TENANT_ID` (defaults `operator@dashboard` / `default`). Client-supplied
`by` / `tenantId` on those routes are ignored. This is still a shared static token,
not per-user OIDC.
- G3 quorum, G6 appeal, and enforcement `strict`/`audit` modes.
- Hash-chained audit receipts (`npm run audit:verify`).
- Company-day demos do not auto-approve exceptions unless a caller passes
Expand All @@ -30,7 +34,9 @@ Treat it as a design artifact you can run locally.
When `CORPOS_ALLOW_UNAUTHENTICATED` is unset, `DASHBOARD_API_TOKEN` is required as a **Bearer**
token on API approve and kill mutations. `CORPOS_MODE=local` (or any value other than
an explicit opt-in) does not skip the gate. The ops console sends
`VITE_DASHBOARD_API_TOKEN` when configured. This is a
`VITE_DASHBOARD_API_TOKEN` when configured. The server derives the decider and tenant
from `DASHBOARD_OPERATOR_ID` / `DASHBOARD_TENANT_ID`, not from the request body, and
rejects decide/appeal when the exception tenant does not match. This is a
static token compare for demos, not an OAuth flow.

## Dependency / supply-chain hygiene
Expand All @@ -43,7 +49,7 @@ static token compare for demos, not an OAuth flow.
`./scripts/harness/verify.sh` fails if `npm approve-scripts --allow-scripts-pending` reports gaps.
- Verify does **not** wipe `node_modules` by default (avoids concurrent `ENOTEMPTY` races).
CI sets `CORPOS_VERIFY_CLEAN=1` for a full reinstall. Never fetch npm via `npx` inside verify.
- `@hono/node-server` is pinned to `2.0.11` via root dependency + `overrides` (covers API
- `@hono/node-server` is pinned to `2.0.12` via root dependency + `overrides` (covers API
`serveStatic` and `@modelcontextprotocol/sdk`).
- Review Dependabot PRs before merge; re-run `npm approve-scripts --allow-scripts-pending`
after lockfile changes that introduce new install scripts.
Expand Down
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
},
"dependencies": {
"@corpos/core": "0.2.0",
"@hono/node-server": "^2.0.11",
"@hono/node-server": "^2.0.12",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.32"
"hono": "^4.13.7"
},
"devDependencies": {
"tsx": "^4.23.0",
Expand Down
65 changes: 57 additions & 8 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

export const DEFAULT_DASHBOARD_OPERATOR_ID = "operator@dashboard";
export const DEFAULT_DASHBOARD_TENANT_ID = "default";

export type DashboardIdentity = {
operatorId: string;
tenantId: string;
};

/**
* Identity bound to the shared dashboard bearer (or unauthenticated opt-in).
* Never read client-supplied `by` / `tenantId` from the request body.
*/
export function dashboardIdentity(): DashboardIdentity {
const operatorId = process.env.DASHBOARD_OPERATOR_ID?.trim() || DEFAULT_DASHBOARD_OPERATOR_ID;
const tenantId = process.env.DASHBOARD_TENANT_ID?.trim() || DEFAULT_DASHBOARD_TENANT_ID;
return { operatorId, tenantId };
}

/** Dashboard bearer gate — exported for adversarial behavioral probes. */
export function requireAuth(c: { req: { header: (n: string) => string | undefined } }): boolean {
// Ungated simulation only with an explicit opt-in (FO-017). CORPOS_MODE !== "shared" must not imply allow.
Expand All @@ -38,6 +56,24 @@ export function requireAuth(c: { req: { header: (n: string) => string | undefine
return header === `Bearer ${expected}`;
}

type ExceptionRow = (typeof exceptions)["$inferSelect"];

type BoundException =
| { status: 200; ex: ExceptionRow }
| { status: 403; error: string }
| { status: 404; error: string };

async function bindExceptionTenant(
company: Company,
exceptionId: string,
tenantId: string,
): Promise<BoundException> {
const ex = (await company.db.select().from(exceptions).where(eq(exceptions.id, exceptionId)))[0];
if (!ex) return { status: 404, error: "not found" };
if (ex.tenantId !== tenantId) return { status: 403, error: "cross-tenant approval denied" };
return { status: 200, ex };
}

function loadAibom(): unknown {
const candidates = [
path.resolve(process.cwd(), "docs/aibom.json"),
Expand Down Expand Up @@ -98,32 +134,45 @@ export function buildApp(company: Company, mode: "simulation" | "live" = "simula
app.post("/api/exceptions/:id/decide", async (c) => {
if (!requireAuth(c)) return c.json({ error: "dashboard authentication required" }, 401);
await expireExceptionTtl(company);
const identity = dashboardIdentity();
const body = await c.req.json<{
decision: "approved" | "rejected";
by?: string;
dissentReason?: string;
}>();
if (body.decision !== "approved" && body.decision !== "rejected") {
return c.json({ error: "decision must be approved|rejected" }, 400);
}
const bound = await bindExceptionTenant(company, c.req.param("id"), identity.tenantId);
if (bound.status !== 200) return c.json({ error: bound.error }, bound.status);
const out = await decideException(
company,
c.req.param("id"),
bound.ex.id,
Comment on lines +145 to +149
body.decision,
body.by ?? "operator",
identity.operatorId,
body.dissentReason,
);
return c.json({ ok: true, ...out });
return c.json({
ok: true,
decidedBy: identity.operatorId,
tenantId: identity.tenantId,
...out,
});
});

app.post("/api/exceptions/:id/appeal", async (c) => {
if (!requireAuth(c)) return c.json({ error: "dashboard authentication required" }, 401);
const body = await c.req.json<{ by?: string; reason?: string }>();
const identity = dashboardIdentity();
const body = await c.req.json<{ reason?: string }>();
const bound = await bindExceptionTenant(company, c.req.param("id"), identity.tenantId);
if (bound.status !== 200) return c.json({ error: bound.error }, bound.status);
const out = await appealException(
company,
c.req.param("id"),
body.by ?? "operator",
bound.ex.id,
identity.operatorId,
body.reason ?? "appeal",
);
if (!out.ok) return c.json(out, 400);
return c.json(out);
return c.json({ ...out, decidedBy: identity.operatorId, tenantId: identity.tenantId });
});

app.post("/api/kill", async (c) => {
Expand Down
1 change: 0 additions & 1 deletion apps/console/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ function App() {
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({
decision,
by: "operator@console",
dissentReason: decision === "rejected" ? dissent || "rejected by operator" : undefined,
}),
});
Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0013-console-hitl-kill-resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ Accepted (corpos-autonomous-company-r3)

## Decision
Console sends `Authorization: Bearer` from `VITE_DASHBOARD_API_TOKEN` on decide/kill.
The API ignores client-supplied `by` / `tenantId` on decide/appeal and binds
`decidedBy` plus tenant matching to `DASHBOARD_OPERATOR_ID` / `DASHBOARD_TENANT_ID`
(defaults `operator@dashboard` / `default`). Cross-tenant attempts return 403.
This remains a shared static token, not per-user OIDC.
`expireTtl` runs on API boot, ~30s interval, and before company-day/decide.
MCP elicitation for privileged tools resolves via the durable exception queue.
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
},
"dependencies": {
"@corpos/core": "0.2.0",
"@hono/node-server": "2.0.11",
"@hono/node-server": "2.0.12",
"drizzle-orm": "^0.45.2"
},
"devDependencies": {
Expand All @@ -48,7 +48,7 @@
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"gifenc": "^1.0.3",
"globals": "^17.7.0",
"globals": "^17.8.0",
"playwright": "^1.52.0",
"pngjs": "^7.0.0",
"prettier": "^3.9.3",
Expand All @@ -64,9 +64,9 @@
"fsevents@2.3.2": true
},
"overrides": {
"@hono/node-server": "2.0.11",
"@hono/node-server": "2.0.12",
"@modelcontextprotocol/sdk": {
"@hono/node-server": "2.0.11"
"@hono/node-server": "2.0.12"
}
}
}
Loading