feat(auth): a credential narrow enough to hand to an agent
`docs/TOOL-CALL-ARCHITECTURE.md` §3 calls deploying the MCP door "config, not code". It is not, and the reason is authentication. `/mcp/skills` authenticates with `AuthService::authenticate`, which returns a full `AuthedUser` carrying the user's role. There is no narrower credential in the system. So pointing a mission container at the door means writing a bearer token into a file inside that container — and mission agents run arbitrary `Bash` with egress and no read gate, which is this platform's own documented security posture. An owner-scoped token there turns "the agent runs commands in a sandbox" into "the agent drives the whole ClawMates API as the owner". Checked before building this rather than assumed: no such credential is in a mission container today. The runtime's config.toml has no `[mcp.servers]` block and no bearer, so the door would have been a NEW exposure, not an existing one. So: `auth_sessions.scope`, defaulting to `full`. `authenticate` now delegates to `authenticate_scoped(token, SCOPE_FULL)`, which means **every existing caller rejects a narrow token** and a route must opt in by naming the scope it accepts. `/mcp/skills` is the only opt-in. Fail closed on purpose. The likely mistake here is adding a scope and forgetting to wire its check; this way that mistake grants nothing rather than granting everything. `mint_scoped` refuses to mint a `full` token — a caller reaching for it wants a narrow credential, and handing back a full one because an argument was wrong is exactly the failure the column exists to prevent, and it would be invisible because the token would work. The test that matters is not that the door accepts the token, it is that nothing else does. Negative-controlled: removing the scope comparison fails `a_scoped_token_is_refused_by_every_unscoped_caller`. `.sqlx` regenerated — `authenticate` is a compile-checked query and CI builds with SQLX_OFFLINE=true. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
This commit is contained in:
co-authored by
Claude Opus 5
parent
8591585e60
commit
2668191e30
@@ -125,3 +125,81 @@ async fn tokens_are_unique_per_login() {
|
||||
auth.authenticate(a.secret()).await.unwrap();
|
||||
auth.authenticate(b.secret()).await.unwrap();
|
||||
}
|
||||
|
||||
/// A narrow credential must be refused everywhere it was not explicitly
|
||||
/// allowed.
|
||||
///
|
||||
/// This is the whole security property. The skills token lives in a file
|
||||
/// inside a mission container, where an agent running arbitrary `Bash` can
|
||||
/// read it — so what matters is not that `/mcp/skills` accepts it but that
|
||||
/// **nothing else does**.
|
||||
#[tokio::test]
|
||||
async fn a_scoped_token_is_refused_by_every_unscoped_caller() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let (_ws, user) = seeded(&pool).await;
|
||||
let auth = AuthService::new(pool);
|
||||
|
||||
let narrow = auth
|
||||
.mint_scoped(user.id, cm_auth::SCOPE_SKILLS_READ, time::Duration::hours(1))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// `authenticate` is what every ordinary route calls.
|
||||
assert!(
|
||||
matches!(
|
||||
auth.authenticate(&narrow).await,
|
||||
Err(AuthError::Unauthenticated)
|
||||
),
|
||||
"a skills token must not authenticate a normal API call — the token is \
|
||||
readable by the agent it is given to"
|
||||
);
|
||||
|
||||
// And it is refused for a DIFFERENT narrow scope, not just for `full`.
|
||||
assert!(matches!(
|
||||
auth.authenticate_scoped(&narrow, "some:other").await,
|
||||
Err(AuthError::Unauthenticated)
|
||||
));
|
||||
|
||||
// It does work for the one thing it is for.
|
||||
let ok = auth
|
||||
.authenticate_scoped(&narrow, cm_auth::SCOPE_SKILLS_READ)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ok.user_id, user.id);
|
||||
}
|
||||
|
||||
/// A person's session keeps working everywhere, including the scoped route.
|
||||
#[tokio::test]
|
||||
async fn a_full_session_still_satisfies_a_scoped_route() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let (_ws, user) = seeded(&pool).await;
|
||||
let auth = AuthService::new(pool);
|
||||
auth.set_password(user.id, "correct horse battery staple")
|
||||
.await
|
||||
.unwrap();
|
||||
let token = auth
|
||||
.login_local("[email protected]", "correct horse battery staple")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(auth
|
||||
.authenticate_scoped(token.secret(), cm_auth::SCOPE_SKILLS_READ)
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
/// `mint_scoped` must refuse to mint a full token.
|
||||
///
|
||||
/// A caller reaching for this wants a narrow credential; handing back a full
|
||||
/// one because an argument was wrong is precisely the failure the scope column
|
||||
/// exists to prevent, and it would be invisible — the token would work.
|
||||
#[tokio::test]
|
||||
async fn mint_scoped_refuses_to_mint_a_full_token() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let (_ws, user) = seeded(&pool).await;
|
||||
let auth = AuthService::new(pool);
|
||||
assert!(auth
|
||||
.mint_scoped(user.id, cm_auth::SCOPE_FULL, time::Duration::hours(1))
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user