@deployfoundation/foundation-deploy 0.2.46 → 0.2.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -204,6 +204,16 @@ COPY packages/infra/agent-image/bin/git-credential-sky \
204
204
  # <app-id>+<app-slug>[bot] is what links a commit to the GitHub App.
205
205
  RUN chmod 0755 /opt/sky/bin/git-credential-sky /opt/sky/bin/gh /opt/sky/bin/aws /opt/sky/bin/gh-real \
206
206
  && printf '[credential "https://github.com"]\n\thelper = sky\n\tuseHttpPath = true\n[user]\n\tname = sky[bot]\n\temail = %s+sky[bot]@users.noreply.github.com\n' "${FOUNDATION_GITHUB_APP_ID}" > /etc/gitconfig
207
+ # Pushes to main/master: refused unless config.yaml's github.directPush names the
208
+ # repo and every changed path (git-hooks/pre-push; the agent exports the list as
209
+ # FOUNDATION_DIRECT_PUSH each turn). core.hooksPath is set through GIT_CONFIG_*,
210
+ # which git ranks like `-c`, above a cloned repo's own config, so a repo cannot
211
+ # switch the guard off; the hook then runs that repo's own pre-push hook.
212
+ COPY packages/infra/agent-image/git-hooks/pre-push /opt/sky/git-hooks/pre-push
213
+ RUN chmod 0755 /opt/sky/git-hooks/pre-push
214
+ ENV GIT_CONFIG_COUNT=1 \
215
+ GIT_CONFIG_KEY_0=core.hooksPath \
216
+ GIT_CONFIG_VALUE_0=/opt/sky/git-hooks
207
217
  # --- Ambient AWS (aws-broker.ts) --------------------------------------------
208
218
  # AWS CLI v2 as /opt/sky/bin/aws-real, fronted by the wrapper copied above.
209
219
  # /opt/sky/aws is where server.ts WRITES the generated profile file at boot,
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Foundation's guard on pushes to a default branch (main or master).
3
+
4
+ git runs this before every push from the agent's sandbox: the image sets
5
+ core.hooksPath through GIT_CONFIG_COUNT/KEY/VALUE, which git treats like `-c`
6
+ and so ranks above a repository's own config. A push that updates
7
+ refs/heads/main or refs/heads/master is refused unless the instance's
8
+ `github.directPush` (FOUNDATION_DIRECT_PUSH, JSON, set by the agent from
9
+ config.yaml every turn) names this repository AND every file the push changes
10
+ sits under one of its paths. Deleting or creating main, or a push that is not a
11
+ fast-forward, is always refused. Pushes to any other branch pass. The
12
+ repository's own pre-push hook, if it has one, runs afterwards.
13
+
14
+ stdin, one line per ref: <local ref> <local sha> <remote ref> <remote sha>
15
+ argv: <remote name> <remote url>
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import subprocess
22
+ import sys
23
+
24
+ DEFAULT_BRANCHES = ("refs/heads/main", "refs/heads/master")
25
+ REPO_FROM_URL = re.compile(r"^https://(?:[^@/]+@)?github\.com/([^/]+/[^/]+?)(?:\.git)?/?$")
26
+
27
+
28
+ def git(*args: str) -> str:
29
+ return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout
30
+
31
+
32
+ def is_zero(sha: str) -> bool:
33
+ return set(sha) == {"0"}
34
+
35
+
36
+ def allowed_paths(repo: str) -> list[str]:
37
+ try:
38
+ rules = json.loads(os.environ.get("FOUNDATION_DIRECT_PUSH") or "[]")
39
+ except ValueError:
40
+ return []
41
+ for rule in rules if isinstance(rules, list) else []:
42
+ if isinstance(rule, dict) and str(rule.get("repo", "")).lower() == repo.lower():
43
+ return [p for p in rule.get("paths", []) if isinstance(p, str) and p]
44
+ return []
45
+
46
+
47
+ def under(path: str, allowed: list[str]) -> bool:
48
+ return any(path.startswith(a) if a.endswith("/") else path == a for a in allowed)
49
+
50
+
51
+ def refuse(message: str) -> None:
52
+ print(f"Foundation: {message}", file=sys.stderr)
53
+ sys.exit(1)
54
+
55
+
56
+ def check(remote_url: str, lines: list[str]) -> None:
57
+ match = REPO_FROM_URL.match(remote_url)
58
+ repo = match.group(1) if match else ""
59
+ for line in lines:
60
+ parts = line.split()
61
+ if len(parts) != 4:
62
+ continue
63
+ _local_ref, local_sha, remote_ref, remote_sha = parts
64
+ if remote_ref not in DEFAULT_BRANCHES:
65
+ continue
66
+ branch = remote_ref.removeprefix("refs/heads/")
67
+ allowed = allowed_paths(repo) if repo else []
68
+ if not allowed:
69
+ refuse(
70
+ f"never push to {branch}. Push a branch and open a pull request"
71
+ f" ({repo or remote_url} has no github.directPush exception)."
72
+ )
73
+ if is_zero(local_sha) or is_zero(remote_sha):
74
+ refuse(f"deleting or creating {branch} is never allowed.")
75
+ try:
76
+ git("merge-base", "--is-ancestor", remote_sha, local_sha)
77
+ except subprocess.CalledProcessError:
78
+ refuse(f"{branch} moved or is not a fast-forward: pull, re-apply your change, push again.")
79
+ changed = [p for p in git("diff", "--name-only", "-z", remote_sha, local_sha).split("\0") if p]
80
+ outside = [p for p in changed if not under(p, allowed)]
81
+ if outside:
82
+ refuse(
83
+ f"this push to {branch} changes files outside {', '.join(allowed)}: "
84
+ f"{', '.join(outside[:5])}. Push a branch and open a pull request."
85
+ )
86
+
87
+
88
+ def run_repository_hook(argv: list[str], stdin: str) -> int:
89
+ """The repository's own pre-push hook: its core.hooksPath, else .git/hooks."""
90
+ try:
91
+ hooks = git("config", "--local", "--get", "core.hooksPath").strip()
92
+ except subprocess.CalledProcessError:
93
+ hooks = ""
94
+ if hooks == "":
95
+ hooks = os.path.join(git("rev-parse", "--git-common-dir").strip(), "hooks")
96
+ elif not os.path.isabs(hooks):
97
+ hooks = os.path.join(git("rev-parse", "--show-toplevel").strip(), hooks)
98
+ hook = os.path.join(hooks, "pre-push")
99
+ if os.path.realpath(hook) == os.path.realpath(__file__) or not os.access(hook, os.X_OK):
100
+ return 0
101
+ return subprocess.run([hook, *argv], input=stdin, text=True).returncode
102
+
103
+
104
+ def main() -> None:
105
+ argv = sys.argv[1:]
106
+ stdin = sys.stdin.read()
107
+ check(argv[1] if len(argv) > 1 else "", stdin.splitlines())
108
+ sys.exit(run_repository_hook(argv, stdin))
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
@@ -88,13 +88,24 @@ bash:
88
88
  action: deny
89
89
  - pattern: "gh repo delete*"
90
90
  action: deny
91
- - pattern: "git push*origin main*"
91
+ # Pushes to main/master are decided by the image's pre-push hook
92
+ # (git-hooks/pre-push), which sees the actual refs and changed paths
93
+ # however the push is spelled, and allows only config.yaml's
94
+ # github.directPush paths. These rules keep the hook from being skipped
95
+ # or re-pointed; GitHub branch protection would be the server-side backstop.
96
+ - pattern: "*--no-verify*"
92
97
  action: deny
93
- - pattern: "git push*origin master*"
98
+ - pattern: "*GIT_CONFIG*"
94
99
  action: deny
95
- - pattern: "git push*HEAD:main*"
100
+ - pattern: "*hooksPath*"
96
101
  action: deny
97
- - pattern: "git push*HEAD:master*"
102
+ - pattern: "*hookspath*"
103
+ action: deny
104
+ - pattern: "*HOOKSPATH*"
105
+ action: deny
106
+ - pattern: "*FOUNDATION_DIRECT_PUSH*"
107
+ action: deny
108
+ - pattern: "*git-hooks*"
98
109
  action: deny
99
110
  # Ambient AWS (aws-broker.ts). BELT AND BRACES ONLY: the real guard is
100
111
  # IAM — every profile fetches credentials for a role carrying AWS's
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-cazzxewf.js";
11
+ } from "../chunk-yn8x77x6.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-32ytmv5k.js";
17
+ } from "../chunk-k6mcv6dk.js";
18
18
  import"../chunk-dz91avrb.js";
19
19
 
20
20
  // bin/app.ts
@@ -22,7 +22,7 @@ import {
22
22
  slackCommandPrefix,
23
23
  verifyManifest,
24
24
  versionedConfigKey
25
- } from "../chunk-32ytmv5k.js";
25
+ } from "../chunk-k6mcv6dk.js";
26
26
  import {
27
27
  DEFAULT_RELEASE_BUCKET,
28
28
  expectedKeyArn
@@ -1433,6 +1433,14 @@ var RawSubagents = z5.object({
1433
1433
  maxRecursionDepth: z5.number().int().min(1).max(3).default(2),
1434
1434
  effort: z5.boolean().default(true)
1435
1435
  }).strict().default({});
1436
+ var GITHUB_REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
1437
+ var DIRECT_PUSH_PATH_RE = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9_.\/-]+$/;
1438
+ var RawGithub2 = z5.object({
1439
+ directPush: z5.array(z5.object({
1440
+ repo: z5.string().regex(GITHUB_REPO_RE, "repo must be owner/name"),
1441
+ paths: z5.array(z5.string().regex(DIRECT_PUSH_PATH_RE, "paths are repo-relative folders or files: no leading /, no .., no globs")).min(1)
1442
+ }).strict()).default([])
1443
+ }).strict().default({});
1436
1444
  var RawMemory = z5.object({ ompBackend: z5.enum(["off", "local"]).default("off") }).strict().default({});
1437
1445
  var SKILL_SOURCE_SCOPES = ["shared", "instance"];
1438
1446
  var RawSkillSourceMetadata = {
@@ -1467,7 +1475,8 @@ var RawInstanceConfig = z5.object({
1467
1475
  capabilities: RawCapabilities,
1468
1476
  jobs: RawJobs,
1469
1477
  subagents: RawSubagents,
1470
- memory: RawMemory
1478
+ memory: RawMemory,
1479
+ github: RawGithub2
1471
1480
  }).strict();
1472
1481
  function crmOutreachConfigured(outreach) {
1473
1482
  return outreach.eligibleStatuses.length > 0;
@@ -1522,6 +1531,12 @@ function parseInstanceConfig(yamlText) {
1522
1531
  effort: raw.subagents.effort
1523
1532
  },
1524
1533
  memory: { ompBackend: raw.memory.ompBackend },
1534
+ github: {
1535
+ directPush: raw.github.directPush.map((rule) => ({
1536
+ repo: rule.repo,
1537
+ paths: [...rule.paths]
1538
+ }))
1539
+ },
1525
1540
  capabilities: raw.capabilities,
1526
1541
  jobs: raw.jobs
1527
1542
  };
@@ -24,7 +24,7 @@ import {
24
24
  scheduledJobBindings,
25
25
  slackCommandPrefixes,
26
26
  webhookJobBindings
27
- } from "./chunk-32ytmv5k.js";
27
+ } from "./chunk-k6mcv6dk.js";
28
28
 
29
29
  // src/stacks/agent-stack.ts
30
30
  import * as cdk from "aws-cdk-lib";
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-cazzxewf.js";
13
+ } from "../chunk-yn8x77x6.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -44,7 +44,7 @@ import {
44
44
  skillsKey,
45
45
  skipBundle,
46
46
  verifyManifest
47
- } from "../chunk-32ytmv5k.js";
47
+ } from "../chunk-k6mcv6dk.js";
48
48
  import"../chunk-dz91avrb.js";
49
49
  export {
50
50
  BUILD_TIMEOUT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,