diff --git a/.agent/hooks.json b/.agent/hooks.json index fe5fa23..268226e 100644 --- a/.agent/hooks.json +++ b/.agent/hooks.json @@ -14,6 +14,21 @@ } ] }, + "plan-guard": { + "enabled": true, + "PreToolUse": [ + { + "matcher": "run_command", + "hooks": [ + { + "type": "command", + "command": "node hooks/plan-guard.mjs", + "timeout": 10 + } + ] + } + ] + }, "context-anchor": { "enabled": true, "PreInvocation": [ @@ -25,4 +40,3 @@ ] } } - diff --git a/.agent/hooks/branch-guard.mjs b/.agent/hooks/branch-guard.mjs index ddbea93..42ecf85 100755 --- a/.agent/hooks/branch-guard.mjs +++ b/.agent/hooks/branch-guard.mjs @@ -1,21 +1,20 @@ #!/usr/bin/env node -/** - * ============================================================================== - * Agentic Android Delivery Kernel — Native Branch Guard Hook (PreToolUse) - * ============================================================================== - * Intercepts write_to_file and replace_file_content tool calls. - * Blocks modifications to repository source files if the current git branch - * matches the default branch (e.g. main/master), enforcing Gate 1.4 and Rule 0. - * ============================================================================== - */ - import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; + +function getRepoRoot() { + try { + return execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return process.cwd(); + } +} function getBlockedBranches() { try { - const configPath = path.resolve(process.cwd(), 'kernel.config.json'); + const configPath = path.resolve(getRepoRoot(), 'kernel.config.json'); if (fs.existsSync(configPath)) { const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); const def = cfg?.git?.defaultBranch || 'main'; @@ -25,54 +24,54 @@ function getBlockedBranches() { return ['main', 'master']; } -let input = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => { input += chunk; }); +function isArtifactPath(targetFile, repoRoot) { + if (!targetFile || typeof targetFile !== 'string') return false; + const resolved = path.resolve(repoRoot, targetFile); + const rel = path.relative(repoRoot, resolved); + if (!rel.startsWith('..') && !path.isAbsolute(rel)) return false; -process.stdin.on('end', () => { - try { - const payload = JSON.parse(input || '{}'); - const toolCall = payload.toolCall || {}; - const args = toolCall.args || {}; - const targetFile = args.TargetFile || ''; + return resolved.includes('/.gemini/antigravity/brain/') || + resolved.includes('/.system_generated/') || + Boolean(process.env.APP_DATA_DIR && resolved.startsWith(path.resolve(process.env.APP_DATA_DIR))); +} - // Allow writes to Antigravity brain artifacts, system logs, or scratchpads - const isArtifact = targetFile.includes('/.gemini/antigravity/brain/') || - targetFile.includes('/.system_generated/') || - targetFile.endsWith('implementation_plan.md') || - targetFile.endsWith('walkthrough.md'); +function inspectFileWrite(targetFile, currentBranch, blockedBranches, repoRoot) { + if (isArtifactPath(targetFile, repoRoot)) return { allow: true }; - if (isArtifact) { - process.stdout.write(JSON.stringify({ decision: 'allow' })); - process.exit(0); - } + if (blockedBranches.includes(currentBranch)) { + return { + allow: false, + reason: `[Rule 0 Violation S-04] Direct modification to '${targetFile}' on '${currentBranch}' is blocked. Cut a dedicated feature branch first.` + }; + } + return { allow: true }; +} - // Determine current git branch - let currentBranch = ''; +const isMain = Boolean(process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])); +if (isMain) { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { input += chunk; }); + process.stdin.on('end', () => { try { - currentBranch = execSync('git branch --show-current', { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'] - }).trim(); - } catch { - currentBranch = ''; - } + const payload = JSON.parse(input || '{}'); + const targetFile = payload?.toolCall?.args?.TargetFile || ''; + const repoRoot = getRepoRoot(); + let currentBranch = ''; + try { + currentBranch = execSync('git branch --show-current', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch {} - const blockedBranches = getBlockedBranches(); - if (blockedBranches.includes(currentBranch)) { - const response = { - decision: 'deny', - reason: `[Rule 0 Violation] Direct modification to '${targetFile}' on branch '${currentBranch}' is strictly blocked.\n` + - `You must complete Phase 1 Inception, post the 4-Pillar Spec, obtain explicit written approval at Gate 1.4, ` + - `and cut a dedicated branch (/issue--) before modifying repository files.` - }; - process.stdout.write(JSON.stringify(response)); - process.exit(0); + const res = inspectFileWrite(targetFile, currentBranch, getBlockedBranches(), repoRoot); + if (!res.allow) { + process.stdout.write(JSON.stringify({ decision: 'deny', reason: res.reason })); + process.exit(0); + } + process.stdout.write(JSON.stringify({ decision: 'allow' })); + } catch { + process.stdout.write(JSON.stringify({ decision: 'allow' })); } + }); +} - process.stdout.write(JSON.stringify({ decision: 'allow' })); - } catch { - // Fail-safe to allow in case of unparseable payload - process.stdout.write(JSON.stringify({ decision: 'allow' })); - } -}); +export { isArtifactPath, inspectFileWrite, getBlockedBranches, getRepoRoot }; diff --git a/.agent/hooks/plan-guard.mjs b/.agent/hooks/plan-guard.mjs new file mode 100755 index 0000000..a04c3d7 --- /dev/null +++ b/.agent/hooks/plan-guard.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +function getRepoRoot() { + try { + return execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return process.cwd(); + } +} + +function getBlockedBranches() { + try { + const configPath = path.resolve(getRepoRoot(), 'kernel.config.json'); + if (fs.existsSync(configPath)) { + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const def = cfg?.git?.defaultBranch || 'main'; + return [def, 'main', 'master'].filter((v, i, a) => a.indexOf(v) === i); + } + } catch {} + return ['main', 'master']; +} + +function tokenize(cmd) { + return cmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; +} + +function inspectCommand(commandLine, currentBranch, blockedBranches) { + if (!commandLine || typeof commandLine !== 'string') return { allow: true }; + + // 1. Hook Path Tampering: Block overriding core.hooksPath or GIT_HOOKS_PATH + if (/-c\s*core\.hooksPath/i.test(commandLine) || + /\bGIT_HOOKS_PATH\b/i.test(commandLine) || + /\bcore\.hooksPath\s*=/i.test(commandLine)) { + return { + allow: false, + reason: "[Security Violation S-02] Tampering with 'core.hooksPath' is strictly prohibited. Pre-commit airbag cannot be deactivated." + }; + } + + const segments = commandLine.split(/(?:&&|\|\||[;\n|&])/).map(s => s.trim()).filter(Boolean); + + for (const segment of segments) { + const tokens = tokenize(segment); + let i = 0; + while (i < tokens.length && /^[a-zA-Z_]\w*=/.test(tokens[i])) i++; + + if (i >= tokens.length || tokens[i] !== 'git') continue; + i++; + + const flagsWithArg = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--exec-path', '--config-env']); + let subcommand = null; + let subArgs = []; + + while (i < tokens.length) { + const t = tokens[i]; + if (t.startsWith('--')) { + i += (!t.includes('=') && flagsWithArg.has(t)) ? 2 : 1; + } else if (t.startsWith('-')) { + i += flagsWithArg.has(t) ? 2 : 1; + } else { + subcommand = t; + subArgs = tokens.slice(i + 1); + break; + } + } + + if (!subcommand) continue; + + if (subcommand === 'commit') { + const hasNoVerify = subArgs.some(a => (!a.startsWith('"') && !a.startsWith("'")) && (a === '--no-verify' || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a))); + if (hasNoVerify) { + return { + allow: false, + reason: "[Airbag Bypass S-02] 'git commit' with '--no-verify' or '-n' is strictly prohibited. All commits must pass the airbag." + }; + } + + if (blockedBranches.includes(currentBranch)) { + return { + allow: false, + reason: `[Rule 0 Violation S-01] Direct 'git commit' on protected branch '${currentBranch}' is blocked. Cut a dedicated feature branch first.` + }; + } + } + + if (subcommand === 'push') { + if (subArgs.some(a => a === '--no-verify')) { + return { allow: false, reason: "[Airbag Bypass S-02] 'git push' with '--no-verify' is strictly prohibited." }; + } + + if (blockedBranches.includes(currentBranch)) { + return { allow: false, reason: `[Security Violation S-01] Direct 'git push' from protected branch '${currentBranch}' is strictly blocked.` }; + } + + const targetsBlocked = subArgs.some(a => { + const c = a.replace(/^['"]|['"]$/g, ''); + return blockedBranches.includes(c) || c.endsWith(':main') || c.endsWith(':master') || c === 'origin/main' || c === 'origin/master'; + }); + + if (targetsBlocked) { + return { allow: false, reason: "[Security Violation S-01] Direct 'git push' targeting protected branch 'main'/'master' is strictly prohibited." }; + } + } + + if (subcommand === 'merge' && blockedBranches.includes(currentBranch)) { + return { allow: false, reason: `[Rule 0 Violation S-01] Direct 'git merge' on protected branch '${currentBranch}' is blocked. Merge via PR at Gate 3.5.` }; + } + } + + return { allow: true }; +} + +const isMain = Boolean(process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])); +if (isMain) { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { input += chunk; }); + process.stdin.on('end', () => { + try { + const payload = JSON.parse(input || '{}'); + const commandLine = payload?.toolCall?.args?.CommandLine || payload?.toolCall?.args?.command || payload?.toolCall?.args?.Command || ''; + + let currentBranch = ''; + try { + currentBranch = execSync('git branch --show-current', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch {} + + const res = inspectCommand(commandLine, currentBranch, getBlockedBranches()); + if (!res.allow) { + process.stdout.write(JSON.stringify({ decision: 'deny', reason: res.reason })); + process.exit(0); + } + process.stdout.write(JSON.stringify({ decision: 'allow' })); + } catch { + process.stdout.write(JSON.stringify({ decision: 'allow' })); + } + }); +} + +export { inspectCommand, tokenize, getBlockedBranches, getRepoRoot }; diff --git a/.agent/personas/p4-system-architect.md b/.agent/personas/p4-system-architect.md index 0e4dcbb..1811707 100644 --- a/.agent/personas/p4-system-architect.md +++ b/.agent/personas/p4-system-architect.md @@ -9,11 +9,11 @@ tools: - find_by_name - grep_search - list_dir - - run_command (read-only verification: test scripts & rules) - GitHubMCP:add_issue_comment deny: - write_to_file - replace_file_content + - run_command - GitHubMCP:create_issue - GitHubMCP:create_pull_request - GitHubMCP:merge_pull_request diff --git a/scripts/test-runtime-guardrails.mjs b/scripts/test-runtime-guardrails.mjs new file mode 100644 index 0000000..8070f68 --- /dev/null +++ b/scripts/test-runtime-guardrails.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +/** + * ============================================================================== + * Agentic Android Delivery Kernel — Runtime Guardrails Test Suite + * ============================================================================== + * Validates: + * 1. Principle of Least Privilege (PoLP): run_command restricted from P1-P4. + * 2. Plan Guard (plan-guard.mjs): command sanitization, airbag & branch protection. + * 3. Branch Guard (branch-guard.mjs): canonical artifact path hardening (S-04). + * ============================================================================== + */ + +import assert from 'node:assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { inspectCommand } from '../.agent/hooks/plan-guard.mjs'; +import { inspectFileWrite, isArtifactPath } from '../.agent/hooks/branch-guard.mjs'; + +const repoRoot = path.resolve(process.cwd()); +const blockedBranches = ['main', 'master']; + +console.log('🧪 Starting Runtime Guardrails & PoLP Verification Suite...\n'); + +// ------------------------------------------------------------------------------ +// 1. Principle of Least Privilege (PoLP) Persona Audit +// ------------------------------------------------------------------------------ +console.log('📋 1. Verifying Principle of Least Privilege (PoLP) on Personas:'); + +const personasDir = path.resolve(repoRoot, '.agent/personas'); +const personaFiles = [ + 'p1-product-planner.md', + 'p2-design-lead.md', + 'p3-privacy-data.md', + 'p4-system-architect.md' +]; + +for (const pf of personaFiles) { + const content = fs.readFileSync(path.join(personasDir, pf), 'utf8'); + const allowMatch = content.match(/allow:([\s\S]*?)deny:/); + const denyMatch = content.match(/deny:([\s\S]*?)contracts:/); + + assert.ok(allowMatch, `${pf} must have an 'allow:' section`); + assert.ok(denyMatch, `${pf} must have a 'deny:' section`); + + const allowedTools = allowMatch[1]; + const deniedTools = denyMatch[1]; + + assert.ok( + !allowedTools.includes('run_command'), + `❌ PoLP Violation: ${pf} allows 'run_command'` + ); + assert.ok( + deniedTools.includes('run_command'), + `❌ PoLP Violation: ${pf} must deny 'run_command'` + ); + console.log(` ✅ [OK] ${pf}: run_command revoked & denied`); +} + +// ------------------------------------------------------------------------------ +// 2. Plan Guard (plan-guard.mjs) Verification +// ------------------------------------------------------------------------------ +console.log('\n🛡️ 2. Verifying Plan Guard (plan-guard.mjs) Runtime Rejections:'); + +const testCasesPlanGuard = [ + // Airbag Bypass Checks + { + name: 'Reject git commit with --no-verify', + cmd: 'git commit --no-verify -m "bypass"', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Airbag Bypass' + }, + { + name: 'Reject git commit with -n flag', + cmd: 'git commit -n -m "bypass"', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Airbag Bypass' + }, + { + name: 'Reject git commit with combined -nm flag', + cmd: 'git commit -nm "bypass"', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Airbag Bypass' + }, + { + name: 'Reject git push with --no-verify', + cmd: 'git push --no-verify origin feat/issue-3-test', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Airbag Bypass' + }, + + // Hook Path Tampering Checks (S-02) + { + name: 'Reject inline -c core.hooksPath override', + cmd: 'git -c core.hooksPath=/dev/null commit -m "bypass"', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Tampering with \'core.hooksPath\'' + }, + { + name: 'Reject GIT_HOOKS_PATH environment override', + cmd: 'GIT_HOOKS_PATH=/dev/null git commit -m "bypass"', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Tampering with \'core.hooksPath\'' + }, + + // Protected Branch Commit Checks (S-01) + { + name: 'Reject git commit on main', + cmd: 'git commit -m "direct commit"', + branch: 'main', + expectAllow: false, + expectedErrorSubstring: 'Direct \'git commit\' on protected branch' + }, + { + name: 'Reject chained git add && git commit on main', + cmd: 'git add -A && git commit -m "chained bypass"', + branch: 'main', + expectAllow: false, + expectedErrorSubstring: 'Direct \'git commit\' on protected branch' + }, + + // Protected Branch Push Checks (S-01) + { + name: 'Reject direct git push while on main', + cmd: 'git push', + branch: 'main', + expectAllow: false, + expectedErrorSubstring: 'Direct \'git push\' from protected branch' + }, + { + name: 'Reject git push targeting main from feature branch', + cmd: 'git push origin main', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Direct \'git push\' targeting protected branch' + }, + { + name: 'Reject git push targeting HEAD:main', + cmd: 'git push origin HEAD:main', + branch: 'feat/issue-3-test', + expectAllow: false, + expectedErrorSubstring: 'Direct \'git push\' targeting protected branch' + }, + + // Permitted Commands + { + name: 'Allow ./gradlew testDebugUnitTest', + cmd: './gradlew testDebugUnitTest', + branch: 'feat/issue-3-test', + expectAllow: true + }, + { + name: 'Allow ./scripts/quality-check.sh', + cmd: './scripts/quality-check.sh', + branch: 'feat/issue-3-test', + expectAllow: true + }, + { + name: 'Allow git status on any branch', + cmd: 'git status', + branch: 'main', + expectAllow: true + }, + { + name: 'Allow git log -n 5 (flag -n on log is not commit)', + cmd: 'git log -n 5', + branch: 'feat/issue-3-test', + expectAllow: true + }, + { + name: 'Allow normal git commit on feature branch', + cmd: 'git commit -m "feat(security): valid commit"', + branch: 'feat/issue-3-test', + expectAllow: true + }, + { + name: 'Allow normal git push of feature branch', + cmd: 'git push origin feat/issue-3-test', + branch: 'feat/issue-3-test', + expectAllow: true + } +]; + +for (const tc of testCasesPlanGuard) { + const result = inspectCommand(tc.cmd, tc.branch, blockedBranches); + assert.equal( + result.allow, + tc.expectAllow, + `❌ Assertion failed for '${tc.name}': expected allow=${tc.expectAllow}, got allow=${result.allow} (reason: ${result.reason})` + ); + if (!tc.expectAllow && tc.expectedErrorSubstring) { + assert.ok( + result.reason && result.reason.includes(tc.expectedErrorSubstring), + `❌ Expected error substring '${tc.expectedErrorSubstring}', got '${result.reason}'` + ); + } + console.log(` ✅ [OK] ${tc.name}`); +} + +// ------------------------------------------------------------------------------ +// 3. Branch Guard (branch-guard.mjs) Path Hardening Verification (S-04) +// ------------------------------------------------------------------------------ +console.log('\n🔒 3. Verifying Branch Guard (branch-guard.mjs) Path Hardening:'); + +const testCasesBranchGuard = [ + // S-04 Escape Hatch Exploits: Must be DENIED + { + name: 'Deny write to repo file named app/src/main/implementation_plan.md on main', + file: 'app/src/main/implementation_plan.md', + branch: 'main', + expectAllow: false + }, + { + name: 'Deny write to repo file named scripts/walkthrough.md on main', + file: 'scripts/walkthrough.md', + branch: 'main', + expectAllow: false + }, + { + name: 'Deny write to repo root implementation_plan.md on main', + file: 'implementation_plan.md', + branch: 'main', + expectAllow: false + }, + { + name: 'Deny write to source file app/src/main/java/MainActivity.kt on main', + file: 'app/src/main/java/MainActivity.kt', + branch: 'main', + expectAllow: false + }, + + // Legitimate External Brain Artifacts: Must be ALLOWED on any branch + { + name: 'Allow external brain artifact implementation_plan.md', + file: '/Users/nicolasvd/.gemini/antigravity/brain/616e7271-ff38-4732-aead-b484ba0ee39d/implementation_plan.md', + branch: 'main', + expectAllow: true + }, + { + name: 'Allow external brain artifact walkthrough.md', + file: '/Users/nicolasvd/.gemini/antigravity/brain/616e7271-ff38-4732-aead-b484ba0ee39d/walkthrough.md', + branch: 'main', + expectAllow: true + }, + { + name: 'Allow external system_generated step output', + file: '/Users/nicolasvd/.gemini/antigravity/brain/616e7271-ff38-4732-aead-b484ba0ee39d/.system_generated/steps/1/output.txt', + branch: 'main', + expectAllow: true + }, + + // Feature Branch Writes: Must be ALLOWED + { + name: 'Allow source file write on dedicated feature branch', + file: 'app/src/main/java/MainActivity.kt', + branch: 'feat/issue-3-shell-execution-guardrails', + expectAllow: true + } +]; + +for (const tc of testCasesBranchGuard) { + const result = inspectFileWrite(tc.file, tc.branch, blockedBranches, repoRoot); + assert.equal( + result.allow, + tc.expectAllow, + `❌ Assertion failed for '${tc.name}': expected allow=${tc.expectAllow}, got allow=${result.allow} (reason: ${result.reason})` + ); + console.log(` ✅ [OK] ${tc.name}`); +} + +console.log('\n🎉 ALL RUNTIME GUARDRAILS, PoLP & PATH HARDENING TESTS PASSED 100%!'); diff --git a/scripts/validate-docs.sh b/scripts/validate-docs.sh index 56a240c..884b284 100755 --- a/scripts/validate-docs.sh +++ b/scripts/validate-docs.sh @@ -137,6 +137,7 @@ check_executable ".agent/hooks/pre-commit-airbag.sh" "Hook Pré-Commit Airbag" check_executable ".agent/hooks/post-merge-dual-sync.sh" "Hook Post-Merge Dual-Sync" check_file ".agent/hooks.json" "Déclaration Native des Hooks Antigravity" 1000 check_executable ".agent/hooks/branch-guard.mjs" "Hook Branch-Guard PreToolUse" 3000 +check_executable ".agent/hooks/plan-guard.mjs" "Hook Plan-Guard PreToolUse" 5500 check_executable ".agent/hooks/pre-invocation-anchor.sh" "Hook Context-Anchor PreInvocation" 1000 check_executable ".agent/hooks/post-checkout" "Hook Post-Checkout Issue Progress Sync" 2000